Codecs
On this page
Every task that flows through an Apalis backend is serialized before storage and deserialized before your handler receives it. This conversion is handled by a codec — a type that implements the Codec trait. A backend exposes its codec through the WireFormatBackend trait, and you can replace it with the with_codec combinator on BackendExt.
apalis-codec provides ready-made codecs for the most common formats, and the Codec trait is straightforward to implement for custom needs.
The Codec Trait
pub trait Codec<T> {
/// The encoded (stored) representation of `T`.
type Compact;
/// The error type returned if encoding or decoding fails.
type Error;
/// Encode a value into its compact representation.
fn encode(input: &T) -> Result<Self::Compact, Self::Error>;
/// Decode a compact representation back into `T`.
fn decode(compact: &Self::Compact) -> Result<T, Self::Error>;
}The Compact associated type is the backend's storage format — for example Vec<u8> for binary formats or String for text-based ones. T is your job argument type. The codec sits between the two, encoding on the way in and decoding on the way out.
You generally never call Codec methods directly — TaskSink calls encode when pushing, and the backend calls decode before yielding a task from poll_next to the worker.
Available Codecs
apalis-codec ships three codecs behind feature flags:
| Feature | Codec | Compact type | Format |
|---|---|---|---|
json (default) | JsonCodec | Vec<u8> | JSON bytes via serde_json |
msgpack | MsgPackCodec | Vec<u8> | MessagePack via rmp-serde |
bincode | BincodeCodec | Vec<u8> | Bincode via the bincode crate |
All three require your job type to implement serde::Serialize and serde::Deserialize.
JSON (default)
apalis-codec = "1"JSON is the default codec used by most Apalis backends. It produces human-readable payloads, which makes debugging straightforward — you can inspect stored tasks directly in your database.
use apalis_codec::json::JsonCodec;
let encoded: Vec<u8> = JsonCodec::encode(&my_job)?;
let decoded: MyJob = JsonCodec::decode(&encoded)?;MessagePack
apalis-codec = { version = "1", features = ["msgpack"] }MessagePack produces compact binary payloads — typically 20–50% smaller than equivalent JSON. Use it when storage size or serialization throughput matters and human-readable payloads are not required.
use apalis_codec::msgpack::MsgPackCodec;
let encoded: Vec<u8> = MsgPackCodec::encode(&my_job)?;
let decoded: MyJob = MsgPackCodec::decode(&encoded)?;Bincode
apalis-codec = { version = "0.1", features = ["bincode"] }Bincode is a compact, non-self-describing binary format optimised for speed. It produces the smallest payloads of the three options but is the least portable — decoded data must be read with the same type definition it was encoded with.
use apalis_codec::bincode::BincodeCodec;
let encoded: Vec<u8> = BincodeCodec::encode(&my_job)?;
let decoded: MyJob = BincodeCodec::decode(&encoded)?;Choosing a Codec
| Consideration | JSON | MessagePack | Bincode |
|---|---|---|---|
| Human-readable payloads | ✓ | ✗ | ✗ |
| Payload size | Largest | Smaller | Smallest |
| Serialization speed | Moderate | Fast | Fastest |
| Schema flexibility | High | High | Low |
| Debugging ease | Easy | Moderate | Difficult |
| Cross-language compatibility | ✓ | ✓ | ✗ |
JSON is the right default for most applications — readable, flexible, and widely supported. Switch to MessagePack when you have high task volume and want to reduce storage overhead without sacrificing schema flexibility. Use Bincode only in performance-critical, Rust-only pipelines where you control both the producer and consumer.
How Codecs Integrate with Backends
A backend declares its codec through the WireFormatBackend trait, which names the codec, the compact storage type it produces, and gives access to the codec instance:
pub trait WireFormatBackend {
type Codec;
type Compact;
fn codec(&self) -> &Self::Codec;
}Every backend picks a default codec, and all tasks pushed to that backend use it. When you call TaskSink::push, the codec's encode is called automatically. When the worker polls and receives a task, decode is called before the handler sees the arguments.
If encoding fails, a TaskSinkError::CodecError is returned. If decoding fails, the task is surfaced as an error on the worker's stream.
Swapping a Backend's Codec
The codec is no longer fixed to a backend type. BackendExt provides a with_codec combinator that swaps out the serialization codec entirely — JSON, MessagePack, Protobuf, or your own — without touching storage logic:
pub trait BackendExt: Backend {
/// Swaps out the backend's serialization codec entirely (JSON,
/// MessagePack, Protobuf, ...) without touching storage logic.
fn with_codec<NewCodec>(self, codec: NewCodec) -> WithCodec<Self, NewCodec>
where
Self: Sized + BackendConfig,
NewCodec: Codec<Self::Args>,
{
WithCodec::new(self, codec)
}
}with_codec consumes the backend and returns a WithCodec wrapper. The wrapper implements WireFormatBackend, so downstream code sees the new codec and its compact type:
impl<B, NewCodec, Args> WireFormatBackend for WithCodec<B, NewCodec>
where
NewCodec: Codec<Args> + Send + 'static,
B: BackendConfig<Args = Args>,
{
type Codec = NewCodec;
type Compact = NewCodec::Compact;
fn codec(&self) -> &Self::Codec {
&self.codec
}
}Two bounds matter here: the codec must be able to encode and decode the backend's Args type, and it must be Send + 'static so it can live inside a running worker.
Using it looks like this:
use apalis_codec::msgpack::MsgPackCodec;
let backend = PostgresStorage::new(&pool).with_codec(MsgPackCodec);
let worker = WorkerBuilder::new("tasty-banana")
.backend(backend)
.build(task_fn);Because the new codec determines the compact type, make sure the backend's storage can hold it. A backend that stores payloads in a binary column works with a Vec<u8> codec such as MessagePack, but not necessarily with one that produces String. Check the backend's documentation for the compact types it supports.
Implementing a Custom Codec
If the built-in codecs do not suit your needs — for example, you need to store tasks as serde_json::Value for schema-flexible querying, or you have a proprietary binary format — implement Codec directly:
use apalis_core::backend::codec::Codec;
pub struct MyCodec;
impl<T> Codec<T> for MyCodec
where
T: serde::Serialize + for<'de> serde::Deserialize<'de>,
{
type Compact = String;
type Error = serde_json::Error;
fn encode(input: &T) -> Result<String, Self::Error> {
serde_json::to_string(input)
}
fn decode(compact: &String) -> Result<T, Self::Error> {
serde_json::from_str(compact)
}
}Here Compact = String rather than Vec<u8> — useful for backends like PostgreSQL where storing tasks as a TEXT or JSONB column makes them directly queryable with SQL.
Then attach it to a backend with with_codec:
let backend = PostgresStorage::new(&pool).with_codec(MyCodec);Summary
| Codec | Feature flag | Best for |
|---|---|---|
JsonCodec | json (default) | Debuggability, cross-language compatibility |
MsgPackCodec | msgpack | Reduced payload size, high throughput |
BincodeCodec | bincode | Maximum performance in Rust-only pipelines |
| Custom | — | Proprietary formats, queryable storage types |
| Trait | Role |
|---|---|
Codec<T> | Encodes and decodes T to and from a compact representation |
WireFormatBackend | Exposes a backend's codec and compact type |
BackendExt::with_codec | Swaps a backend's codec without changing its storage logic |
Codecs are the only layer in Apalis that touches the raw bytes of your task payload. Keeping them swappable — through a trait and a with_codec combinator, rather than a hardcoded format — means you can change serialization strategy per backend without touching your handler logic.