November 03, 2025 by Dennis Oberst | Comments
In this post, we explore different approaches for data serialization, comparing the key well-known formats for structure data. These are also all readily available for your Qt projects.
We made the comparisons by testing the QDataStream, XML, JSON, CBOR and Protobuf data serialization formats using a realistic scenario. As a result, differences were found in code, data size, and performance in terms of serialization and de-serialization times between each format. You can use these findings to help make informed decisions when choosing which one to use.
You’ll find detailed coverage of:
[Remarks on Testing with Complex, Nested Data](#remarks-on-testing-with-complex-nested-d…
November 03, 2025 by Dennis Oberst | Comments
In this post, we explore different approaches for data serialization, comparing the key well-known formats for structure data. These are also all readily available for your Qt projects.
We made the comparisons by testing the QDataStream, XML, JSON, CBOR and Protobuf data serialization formats using a realistic scenario. As a result, differences were found in code, data size, and performance in terms of serialization and de-serialization times between each format. You can use these findings to help make informed decisions when choosing which one to use.
You’ll find detailed coverage of:
Remarks on Testing with Complex, Nested Data
How Each Data Serialization Format Was Tested
The QDataStream Test
The XML Test
The JSON Test
The CBOR Test
The Protobuf Test
Results: How the Data Serialization Formats Compare
Remarks on Testing with Complex, Nested Data
To properly test these formats, we use a task management data structure with multiple nesting levels and various Qt types including QUuid, [QDateTime](https://doc.qt.io/qt-6/qdatetime.html) or QColor. This created a realistic scenario that challenges each data serialization method with complex, nested data.

The next step was to write our Qt Test Benchmark. We generated a TaskManager with 10,000 tasks to provide substantial data for testing our data serialization formats. We declared the QtCoreSerialization and QtProtobuf functions to handle their respective formats:
#include "task_manager.h"
#include <QtTest/QTest>
TaskManager generateTasks(size_t amount);
class QtSerializationBenchmarks : public QObject
{
Q_OBJECT
public:
QtSerializationBenchmarks()
: m_testData(generateTasks(10'000))
{}
private slots:
void QtCoreSerialization_data() const;
void QtCoreSerialization();
void QtProtobuf();
private:
TaskManager m_testData;
};
How Each Data Serialization Format Was Tested
Now let’s go through the test for each format in detail.
Consistent Interface for QDataStream, XML, JSON, and CBOR
There are various data serialization formats for structured data, as outlined in the Qt Core Serialization overview. We focused on general-purpose formats suitable for complex data structures, excluding application settings.
The QDataStream, XML, JSON, and CBOR formats follow a consistent interface where a serialize function converts the TaskManager into its encoded form, and a deserialize function reconstructs the original object.
We validated each round-trip by comparing the result with the input data:
struct SerializationFormat {
QByteArray (*serialize)(const TaskManager &);
TaskManager (*deserialize)(const QByteArray &);
};
void QtSerializationBenchmarks::QtCoreSerialization_data() const {
QTest::addColumn("format");
QTest::newRow("QDataStream") << SerializationFormat{
serializeDataStream, deserializeDataStream };
QTest::newRow("XML") << SerializationFormat{
serializeXml, deserializeXml };
QTest::newRow("JSON") << SerializationFormat{
serializeJson, deserializeJson };
QTest::newRow("CBOR") << SerializationFormat{
serializeCbor, deserializeCbor };
}
void QtSerializationBenchmarks::QtCoreSerialization()
{
QFETCH(SerializationFormat, format);
QByteArray encodedData;
TaskManager taskManager;
QBENCHMARK {
encodedData = format.serialize(m_testData);
}
QBENCHMARK {
taskManager = format.deserialize(encodedData);
}
QTest::setBenchmarkResult(encodedData.size(), QTest::BytesAllocated);
QCOMPARE_EQ(taskManager, m_testData);
}
The QDataStream Test
QDataStream provides Qt’s native binary data serialization format, using streaming operators for type-safe data handling. Any type can be serialized by implementing the input and output operators.
For serialization, we defined output streaming operators for each data structure, maintaining consistent field order throughout the hierarchy.
#include <QtCore/QDataStream>
QDataStream &operator<<(QDataStream &stream, const TaskHeader &header) {
return stream << header.id << header.name << header.created << header.color;
}
QDataStream &operator<<(QDataStream &stream, const Task &task) {
return stream << task.header << task.description << qint8(task.priority) << task.completed;
}
QDataStream &operator<<(QDataStream &stream, const TaskList &list) {
return stream << list.header << list.tasks;
}
QDataStream &operator<<(QDataStream &stream, const TaskManager &manager) {
return stream << manager.user << manager.version << manager.lists;
}
QByteArray serializeDataStream(const TaskManager &manager)
{
QByteArray data;
QDataStream stream(&data, QIODevice::WriteOnly);
stream.setVersion(QDataStream::Qt_6_10);
stream << manager;
return data;
}
For deserialization, we implemented matching input operators that read fields in the exact same sequence to ensure data integrity.
QDataStream &operator>>(QDataStream &stream, TaskHeader &header) {
return stream >> header.id >> header.name >> header.created >> header.color;
}
QDataStream &operator>>(QDataStream &stream, Task &task) {
qint8 priority;
stream >> task.header >> task.description >> priority >> task.completed;
task.priority = Task::Priority(priority);
return stream;
}
QDataStream &operator>>(QDataStream &stream, TaskList &list) {
return stream >> list.header >> list.tasks;
}
QDataStream &operator>>(QDataStream &stream, TaskManager &manager) {
return stream >> manager.user >> manager.version >> manager.lists;
}
TaskManager deserializeDataStream(const QByteArray &data)
{
TaskManager manager;
QDataStream stream(data);
stream.setVersion(QDataStream::Qt_6_10);
stream >> manager;
return manager;
}
The XML Test
Qt provides XML serialization through QXmlStreamWriter and QXmlStreamReader. Unlike QDataStream’s Qt-specific binary format, XML uses a widely recognized standard that ensures interoperability across different systems and programming languages.
For serialization, we use QXmlStreamWriter to convert our data hierarchy into XML elements and attributes:
#include <QtCore/QXmlStreamWriter>
void encodeXmlHeader(QXmlStreamWriter &writer, const TaskHeader &header) {
writer.writeAttribute("id"_L1, header.id.toString(QUuid::WithoutBraces));
writer.writeAttribute("name"_L1, header.name);
writer.writeAttribute("color"_L1, header.color.name());
writer.writeAttribute("created"_L1, header.created.toString(Qt::ISODate));
}
void encodeXmlTask(QXmlStreamWriter &writer, const Task &task) {
writer.writeStartElement("task"_L1);
encodeXmlHeader(writer, task.header);
writer.writeAttribute("priority"_L1, QString::number(qToUnderlying(task.priority)));
writer.writeAttribute("completed"_L1, task.completed ? "true"_L1 : "false"_L1);
writer.writeCharacters(task.description);
writer.writeEndElement();
}
void encodeXmlTaskList(QXmlStreamWriter &writer, const TaskList &list) {
writer.writeStartElement("tasklist"_L1);
encodeXmlHeader(writer, list.header);
for (const auto &task : list.tasks)
encodeXmlTask(writer, task);
writer.writeEndElement();
}
void encodeXmlTaskManager(QXmlStreamWriter &writer, const TaskManager &manager) {
writer.writeStartElement("taskmanager"_L1);
writer.writeAttribute("user"_L1, manager.user);
writer.writeAttribute("version"_L1, manager.version.toString());
for (const auto &list : manager.lists)
encodeXmlTaskList(writer, list);
writer.writeEndElement();
}
QByteArray serializeXml(const TaskManager &manager)
{
QByteArray data;
QXmlStreamWriter writer(&data);
writer.writeStartDocument();
encodeXmlTaskManager(writer, manager);
writer.writeEndDocument();
return data;
}
For deserialization, QXmlStreamReader processes the XML document sequentially. We traverse elements using readNextStartElement() and extract data from attributes to reconstruct our object hierarchy:
#include <QtCore/QXmlStreamReader>
TaskHeader decodeXmlHeader(const QXmlStreamAttributes &attrs) {
return TaskHeader {
.id = QUuid(attrs.value("id"_L1).toString()),
.name = attrs.value("name"_L1).toString(),
.color = QColor(attrs.value("color"_L1).toString()),
.created = QDateTime::fromString(attrs.value("created"_L1).toString(), Qt::ISODate)
};
}
Task decodeXmlTask(QXmlStreamReader &reader) {
const auto attrs = reader.attributes();
return Task {
.header = decodeXmlHeader(attrs),
.priority = Task::Priority(attrs.value("priority"_L1).toInt()),
.completed = attrs.value("completed"_L1) == "true"_L1,
.description = reader.readElementText(),
};
}
TaskList decodeXmlTaskList(QXmlStreamReader &reader) {
const auto attrs = reader.attributes();
return TaskList {
.header = decodeXmlHeader(attrs),
.tasks = [](auto &reader) {
QList tasks;
while (reader.readNextStartElement() && reader.name() == "task"_L1)
tasks.append(decodeXmlTask(reader));
return tasks;
}(reader)
};
}
TaskManager decodeXmlTaskManager(QXmlStreamReader &reader) {
const auto attrs = reader.attributes();
return TaskManager {
.user = attrs.value("user"_L1).toString(),
.version = QVersionNumber::fromString(attrs.value("version"_L1).toString()),
.lists = [](auto &reader) {
QList taskLists;
while (reader.readNextStartElement() && reader.name() == "tasklist"_L1)
taskLists.append(decodeXmlTaskList(reader));
return taskLists;
}(reader)
};
}
TaskManager deserializeXml(const QByteArray &data)
{
QXmlStreamReader reader(data);
while (reader.readNextStartElement() && reader.name() == "taskmanager")
return decodeXmlTaskManager(reader);
return {};
}
The JSON Test
Qt provides JSON serialization through QJsonDocument, QJsonObject, and QJsonArray. JSON offers a human-readable text format that’s become the standard for web APIs and configuration files.
For serialization, we build a JSON document by converting each data structure to QJsonObject with helper functions for nested types:
#include <QtCore/QJsonArray>
#include <QtCore/QJsonDocument>
#include <QtCore/QJsonObject>
QJsonValue encodeJsonHeader(const TaskHeader &header) {
return QJsonObject{
{ "id"_L1, header.id.toString(QUuid::WithoutBraces) },
{ "name"_L1, header.name },
{ "color"_L1, header.color.name() },
{ "created"_L1, header.created.toString(Qt::ISODate) }
};
}
QJsonValue encodeJsonTask(const Task &task) {
return QJsonObject{
{ "header"_L1, encodeJsonHeader(task.header) },
{ "description"_L1, task.description },
{ "priority"_L1, qToUnderlying(task.priority) },
{ "completed"_L1, task.completed }
};
}
QJsonValue encodeJsonTaskList(const TaskList &list) {
return QJsonObject{
{ "header"_L1, encodeJsonHeader(list.header) },
{ "tasks"_L1, [](const auto &tasks) {
QJsonArray array;
for (const auto &t : tasks)
array.append(encodeJsonTask(t));
return array;
}(list.tasks) }
};
}
QJsonValue encodeJsonTaskManager(const TaskManager &manager) {
return QJsonObject{
{ "user"_L1, manager.user },
{ "version"_L1, manager.version.toString() },
{ "lists"_L1, [](const auto &lists) {
QJsonArray array;
for (const auto &l : lists)
array.append(encodeJsonTaskList(l));
return array;
}(manager.lists) }
};
}
QByteArray serializeJson(const TaskManager &manager)
{
return QJsonDocument(encodeJsonTaskManager(manager))
.toJson(QJsonDocument::Compact);
}
For deserialization, we parse the JSON document and extract values by key, converting QJsonArray to lists and QJsonObject to nested structures:
TaskHeader decodeJsonHeader(const QJsonObject &obj) {
return {
.id = QUuid(obj["id"_L1].toString()),
.name = obj["name"_L1].toString(),
.color = QColor(obj["color"_L1].toString()),
.created = QDateTime::fromString(obj["created"_L1].toString(), Qt::ISODate)
};
}
Task decodeJsonTask(const QJsonObject &obj) {
return {
.header = decodeJsonHeader(obj["header"_L1].toObject()),
.priority = Task::Priority(obj["priority"_L1].toInt()),
.completed = obj["completed"_L1].toBool(),
.description = obj["description"_L1].toString()
};
}
TaskList decodeJsonTaskList(const QJsonObject &obj) {
return {
.header = decodeJsonHeader(obj["header"_L1].toObject()),
.tasks = [](const auto &obj) {
QList tasks;
for (const auto &taskValue : obj["tasks"_L1].toArray())
tasks.append(decodeJsonTask(taskValue.toObject()));
return tasks;
}(obj)
};
}
TaskManager decodeJsonTaskManager(const QJsonObject &obj) {
return {
.user = obj["user"_L1].toString(),
.version = QVersionNumber::fromString(obj["version"_L1].toString()),
.lists = [](const auto &obj) {
QList lists;
for (const auto &listValue : obj["lists"_L1].toArray())
lists.append(decodeJsonTaskList(listValue.toObject()));
return lists;
}(obj)
};
}
TaskManager deserializeJson(const QByteArray &data)
{
const auto jsonRoot = QJsonDocument::fromJson(data).object();
return decodeJsonTaskManager(jsonRoot);
}
The CBOR Test
CBOR (Concise Binary Object Representation) provides a compact binary alternative to JSON. Qt offers native support for it through the QCborValue, QCborMap, and QCborArray classes, as well as the QCborStreamReader and QCborStreamWriter APIs.
The serialization process is very similar to JSON — each field of the TaskManager is written as a key–value pair. However, the resulting data is stored in a compact binary form, which makes it more space-efficient and faster to parse:
#include <QtCore/QCborArray>
#include <QtCore/QCborMap>
#include <QtCore/QCborValue>
QCborMap encodeCborHeader(const TaskHeader &header) {
return {
{ "id"_L1, QCborValue(header.id) },
{ "name"_L1, header.name },
{ "color"_L1, header.color.name() },
{ "created"_L1, QCborValue(header.created) }
};
}
QCborMap encodeCborTask(const Task &task) {
return {
{"header"_L1, encodeCborHeader(task.header)},
{"description"_L1, task.description},
{"priority"_L1, qToUnderlying(task.priority)},
{"completed"_L1, task.completed}
};
}
QCborMap encodeCborTaskList(const TaskList &list) {
return {
{ "header"_L1, encodeCborHeader(list.header) },
{ "tasks"_L1, [](const auto &tasks) {
QCborArray tasksArray;
for (const auto &t : tasks)
tasksArray.append(encodeCborTask(t));
return tasksArray;
}(list.tasks) }
};
}
QCborMap encodeCborTaskManager(const TaskManager &manager) {
return {
{ "user"_L1, manager.user },
{ "version"_L1, manager.version.toString() },
{ "lists"_L1, [](const auto &lists) {
QCborArray listsArray;
for (const auto &l : lists)
listsArray.append(encodeCborTaskList(l));
return listsArray;
}(manager.lists) }
};
}
QByteArray serializeCbor(const TaskManager &manager)
{
return QCborValue(encodeCborTaskManager(manager)).toCbor();
}
For deserialization, the CBOR data is read back into corresponding Qt types using QCborValue or by manually traversing the CBOR structure. This allows an easy round-trip between JSON and CBOR representations while maintaining the same logical structure:
TaskHeader decodeCborHeader(const QCborMap &map) {
return TaskHeader{
.id = map["id"_L1].toUuid(),
.name = map["name"_L1].toString(),
.color = QColor(map["color"_L1].toString()),
.created = map["created"_L1].toDateTime(),
};
}
Task decodeCborTask(const QCborMap &map) {
return Task{
.header = decodeCborHeader(map["header"_L1].toMap()),
.priority = Task::Priority(map["priority"_L1].toInteger()),
.completed = map["completed"_L1].toBool(),
.description = map["description"_L1].toString()
};
}
TaskList decodeCborTaskList(const QCborMap &map) {
return TaskList {
.header = decodeCborHeader(map["header"_L1].toMap()),
.tasks = [](const auto &map) {
QList tasks;
for (const auto &taskValue : map["tasks"_L1].toArray())
tasks.append(decodeCborTask(taskValue.toMap()));
return tasks;
}(map)
};
}
TaskManager decodeCborTaskManager(const QCborMap &map) {
return TaskManager {
.user = map["user"_L1].toString(),
.version = QVersionNumber::fromString(map["version"_L1].toString()),
.lists = [](const auto &map) {
QList lists;
for (const auto &listValue : map["lists"_L1].toArray())
lists.append(decodeCborTaskList(listValue.toMap()));
return lists;
}(map)
};
}
TaskManager deserializeCbor(const QByteArray &data)
{
const auto cborRoot = QCborValue::fromCbor(data).toMap();
return decodeCborTaskManager(cborRoot);
}
The Protobuf Test
Protobuf uses an interface definition language (IDL) to define data structures in .proto files. The protocol buffer compiler then generates the serialization code, making the implementation concise and type-safe. Unlike the other formats where we manually handle serialization logic, Protobuf automatically generates efficient binary serialization code from the schema definition.
We defined our data structure using Protobuf’s IDL syntax. The QtProtobufQtCoreTypes and QtProtobufQtGuiTypes modules provide automatic conversions for Qt types like QUuid, QDateTime or``QColor, allowing seamless integration with Qt’s type system:
syntax = "proto3";
package proto;
import "QtCore/QtCore.proto";
import "QtGui/QtGui.proto";
message TaskHeader {
QtCore.QUuid id = 1;
string name = 2;
QtGui.QColor color = 3;
QtCore.QDateTime crated = 4;
}
message Task {
enum Priority {
Low = 0;
Medium = 1;
High = 2;
Critical = 3;
}
TaskHeader header = 1;
Priority priority = 2;
bool completed = 3;
string description = 4;
}
message TaskList {
TaskHeader header = 1;
repeated Task tasks = 2;
}
message TaskManager {
string user = 1;
QtCore.QVersionNumber version = 2;
repeated TaskList lists = 5;
}
The task_manager.qpb.h header was generated from our .proto file definition. We implemented a conversion operator in the TaskManager struct to bridge between our native type and the generated proto::TaskManager.
After converting our test data to the proto format, the QProtobufSerializer handles the actual binary serialization and deserialization, reducing the complex data transformation to straightforward API calls:
#include "task_manager.qpb.h"
#include <QtProtobuf/QProtobufSerializer>
#include <QtProtobufQtCoreTypes/QtProtobufQtCoreTypes>
#include <QtProtobufQtGuiTypes/QtProtobufQtGuiTypes>
TaskManager::operator proto::TaskManager() const {
proto::TaskManager protoManager;
protoManager.setUser(user);
protoManager.setVersion(version);
auto readHeader = [](TaskHeader header) {
proto::TaskHeader h;
h.setId_proto(header.id);
h.setName(header.name);
h.setColor(header.color);
h.setCrated(header.created);
return h;
};
QList protoLists;
for (const auto &list : lists) {
proto::TaskList protoList;
protoList.setHeader(readHeader(list.header));
QList protoTasks;
for (const auto &task : list.tasks) {
proto::Task t;
t.setHeader(readHeader(task.header));
t.setDescription(task.description);
t.setPriority(proto::Task::Priority(task.priority));
t.setCompleted(task.completed);
protoTasks.append(t);
}
protoList.setTasks(std::move(protoTasks));
protoLists.append(std::move(protoList));
}
protoManager.setLists(std::move(protoLists));
return protoManager;
}
void QtSerializationBenchmarks::QtProtobuf()
{
const proto::TaskManager protoTestData = m_testData;
QtProtobuf::registerProtobufQtCoreTypes();
QtProtobuf::registerProtobufQtGuiTypes();
QProtobufSerializer serializer;
QByteArray encodedData;
proto::TaskManager protoTaskManager;
QBENCHMARK {
encodedData = serializer.serialize(&protoTestData);
}
QBENCHMARK {
serializer.deserialize(&protoTaskManager, encodedData);
}
QTest::setBenchmarkResult(encodedData.size(), QTest::BytesAllocated);
QCOMPARE_EQ(protoTaskManager, protoTestData);
}
Results: How the Data Serialization Formats Compare
We benchmarked each serialization format using a TaskManager containing 10'000 tasks distributed across multiple task lists. The tests were conducted using Qt 6.10.0 on macOS with an ARM64 architecture.
Here’s a summary of the data serialization format test results:
| Format | Serialisation Time | Deserialisation Time | Serialized Size | 
| QDataStream | 0.50 ms | 1 ms | 1127 KB | 
| XML | 6.5 ms | 7.5 ms | 1757 KB | 
| JSON | 14 ms | 6 ms | 2005 KB | 
| CBOR | 10 ms | 6.7 ms | 1691 KB | 
| QtProtobuf | 10 ms | 7.7 ms | 890 KB | 
How QDataStream Compares
QDataStream proved to be the fastest format overall, achieving sub-millisecond serialization times and quick deserialization. Its use of simple streaming operators makes it highly efficient within Qt-based applications. However, since it is tightly coupled to Qt, QDataStream is best suited for internal data handling or temporary storage within Qt environments.
- Fastest performance
 - Highly efficient within Qt applications
 - Limited interoperability
 
When Is XML a Good Choice?
XML offers a human-readable, text-based structure with strong support for standardized schemas. It remains a flexible choice for systems that require transparency or manual editing. Although the resulting files are relatively large due to its verbose syntax, XML performs efficiently in most practical use cases and is well-suited for configuration and data exchange with legacy systems.
- Human-readable and standardized
 - Very flexible
 - Larger file size
 
What Is JSON Best for?
JSON remains a widely adopted format thanks to its universal compatibility and simplicity. It is particularly well-suited for web applications and network protocols, where lightweight data exchange is essential. While deserialization is fast, serialization is comparatively slower, and the resulting files are the largest among the tested formats. Despite these drawbacks, JSON’s readability and broad ecosystem support make it a reliable choice for APIs and cross-platform data interchange.
- Universally compatible and widely adopted
 - Fast deserialization, but slower serialization
 - Larger output size
 
Is CBOR Better than JSON?
CBOR provides a compact binary representation of JSON-like data structures, combining structural familiarity with reduced storage requirements compared to JSON. Although its performance is moderate compared to QDataStream, it offers a good balance between compactness and interoperability. CBOR is especially suitable for network protocols or scenarios where JSON compatibility is desired with improved efficiency.
- Compact format with JSON-like structure
 - Balances efficiency and interoperability
 - Ideal when JSON compatibility is needed with compactness
 
What is Protobuf Best for?
Protobuf achieved the smallest serialized output, thanks to its efficient binary encoding and schema-based structure. It enforces strong typing, making data transmission and storage more predictable and safe. However, the need for code generation and the associated conversion overhead can add complexity to development workflows. Protobuf is particularly advantageous for high-volume data storage or network transmission where bandwidth and space efficiency are critical.
- Smallest serialized size
 - Generated code handles serialization automatically
 - Safer data access by using schema-based types
 
Conclusion
Choosing the right data serialization format depends on your specific use case, whether you want to optimize for performance, size, readability, or cross-platform compatibility. Where QDataStream excels in speed within Qt environments, formats like JSON and XML offer broader interoperability. CBOR and Protobuf provide balance between efficiency and structure, with Protobuf delivering the most compact output.
By understanding the strengths and trade-offs of each data serialization method, you can make informed decisions to optimize your application for speed, scalability, and maintainability.
You can find the benchmark code referenced in this post here.