Parco: An Experiment in Go Serialization
At Atani we built a real-time producer that connected to over 30 cryptocurrency exchanges and DEXes. It streamed trades, orderbook updates and market data through Redpanda and NATS to a crowd of consumers. Millions of messages a day. Microseconds mattered.
We used easyjson for serialization. It worked. JSON is readable and debuggable, and easyjson generates marshal code that’s much faster than the standard library. For our throughput and latency it was enough.
But I kept thinking about waste. Every message carried field names nobody needed. A trade looked like {"exchange":"binance","symbol":"BTC/USDT","price":45000.50,"volume":1.2}. Those names, exchange, symbol, price, volume, went out with every single message. The consumer already knew the structure. Why send it again?
Our NATS topics were hierarchical and explicit: trade.binance.btc.usdt, orderbook.coinbase.eth.usd, ticker.kraken.dot.eur. If you subscribed to trade.binance.btc.usdt you knew exactly what was coming. The topic was the schema. A self-describing message was describing something the subscription had already told you.
That’s the textbook case for schema-aware binary serialization. Both sides know the structure. The topic names the model. Why spend bytes on metadata?
Protocol Buffers is the standard answer, and it felt heavy. We had three or four message types that barely changed. protoc in the build, .proto files to maintain, generated code to live with: a lot of machinery for a small problem. I wanted something lighter that stayed in Go.
So I built Parco as an experiment.
The idea
Define your codec once with a builder, then serialize and deserialize with direct function calls. It uses no reflection at runtime, generates no code and needs no external tools.
type Trade struct {
Exchange string
base string
Quote string
Price float64
Volume float64
Timestamp time.Time
}
tradeParser, tradeCompiler := parco.Builder[Trade]().
Varchar(func(t Trade) string { return t.Exchange }).
Varchar(func(t Trade) string { return t.Symbol }).
Float64LE(func(t Trade) float64 { return t.Price }).
Float64LE(func(t Trade) float64 { return t.Volume }).
TimeUTC(func(t Trade) time.Time { return t.Timestamp }).
Parco()
The builder compiles into a codec that knows the field order and types. Serialization writes bytes in that order. Deserialization reads them back in the same order. Strings get a length prefix. Numbers are little-endian. Times are Unix timestamps. The output carries no field names, type tags or schema version, only the data.
With NATS you pick the codec from the subscription. trade.binance.btc.usdt gets the trade codec. orderbook.coinbase.eth.usd gets the orderbook codec. The topic router already tells message types apart; Parco only has to serialize.
What actually happened
We never deployed Parco to production. It worked: the benchmarks were good, memory use beat easyjson, and the idea held. We didn’t deploy it because easyjson was already good enough and we had other problems. Sometimes good enough is good enough.
Trading systems have thin margins for experiments. When something works, changing it needs a strong reason. Parco would have been faster and lighter, but not by enough to justify the migration risk when the bottlenecks were elsewhere.
That’s production: the interesting solution isn’t always the one you ship.
Why open source it
The experiment was still valid. When both sides know the schema, and especially when a broker routes by topic, schema-aware serialization is the right shape.
Someone else has this problem. They’re streaming Go structs over NATS or Redpanda and wondering why they send field names every time. Protobuf feels too heavy for them too. They want something small that stays in Go.
So I cleaned it up, wrote docs, added tests, and released it: github.com/sonirico/parco.
When it makes sense
Parco fits when you have:
- Go on both ends
- Stable schemas
- A broker with topic-based routing (NATS subjects, Kafka topics, Redpanda streams)
- A reason to care about microseconds and bytes
- No need for other languages
It doesn’t fit when you need self-describing messages, cross-language compatibility or real schema evolution. Use JSON for APIs and debugging. Use Protocol Buffers across languages. Use FlatBuffers for zero-copy access to huge messages.
If you’re building an internal Go-to-Go pipeline and want something simpler than Protobuf, Parco might be it.
The numbers
I benchmarked against JSON and MessagePack. On small messages, about 90 bytes, Parco is 25% faster than JSON. On medium messages, about 750 bytes, 80% faster. On 8KB payloads, roughly twice as fast.
The bigger win is memory. Parco uses constant memory whatever the payload: 184 bytes and 3 allocations per operation. JSON grows with the data. Over millions of messages, fewer allocations means less GC pressure and steadier latency.
Payloads are 50-65% smaller than JSON and 20-40% smaller than MessagePack. Over terabytes a day, that’s real disk.
Full benchmarks and methodology are in PERFORMANCE.md.
The lesson
The lesson isn’t “use Parco.” An experiment that never ships can still pay for itself, and this one did.
Building Parco taught me more about Go’s type system, memory management and serialization trade-offs than any amount of reading. It forced me to decide when schema flexibility matters and when it’s only overhead. It made me better at weighing complexity against benefit.
And someone out there may find it useful. That’s what open source is for.
The name
“Par” from parser, “co” from compiler. And parco means brief in Spanish. It named itself.
Try it
go get github.com/sonirico/parco
Documentation and examples are in the repo. Bugs and suggestions: issues and PRs are welcome. If it doesn’t fit what you’re building, use something else.