Home › Serializers › ArrowSerializer
DataFrame-optimized serializer — Columnar serialization for pandas and polars DataFrames using the Apache Arrow IPC format, zstd-compressed by default.
Best for:
- pandas DataFrames, any size
- polars DataFrames
- Data science workloads
- Time-series data
- High-frequency DataFrame caching
Performance characteristics:
- Payload: Arrow IPC, compressed with zstd by default (
CACHEKIT_ARROW_COMPRESSION), so a read decompresses it - Zero-copy reads: only for uncompressed, plaintext payloads on the File backend; what stays zero-copy depends on
return_formatand the Python version (see Zero-Copy Reads on the File Backend). The stored payload is then larger - Network overhead: Efficient columnar format
StandardSerializer (MessagePack) does not accept DataFrames, so there is no MessagePack figure to compare against. Sizes and instruction counts measured on a real dataset are in Performance on Real Data.
from cachekit import cache
from cachekit.serializers import ArrowSerializer
import pandas as pd
@cache(serializer=ArrowSerializer())
def load_stock_data(symbol: str):
# Returns large DataFrame
return fetch_historical_prices(symbol) # doctest: +SKIPBasic example:
from cachekit import cache
from cachekit.backends.file import FileBackend, FileBackendConfig
from cachekit.serializers import ArrowSerializer
import pandas as pd
# Explicit ArrowSerializer for DataFrame caching. backend=None would keep only the L1
# in-memory copy, which stores the DataFrame object itself and never runs the serializer.
backend = FileBackend(FileBackendConfig(cache_dir="/tmp/cachekit"))
@cache(serializer=ArrowSerializer(), backend=backend)
def get_large_dataset(date: str):
# Load 100K+ row DataFrame (illustrative - file may not exist)
df = pd.read_csv(f"data/{date}.csv")
return df
# Automatic round-trip with pandas DataFrame
df = get_large_dataset("2024-01-01") # Cache miss: loads CSV
df = get_large_dataset("2024-01-01") # Cache hit: no CSV loadArrowSerializer supports multiple return formats for deserialization:
from cachekit.serializers import ArrowSerializer
# Return as pandas DataFrame (default)
serializer = ArrowSerializer(return_format="pandas")
# Return as polars DataFrame (requires polars installed)
serializer = ArrowSerializer(return_format="polars")
# Return as pyarrow.Table (no conversion; zero-copy on an uncompressed File-backend hit on Python 3.13+)
serializer = ArrowSerializer(return_format="arrow")A pandas result accepts in-place edits after a backend hit (df.loc[0, "a"] = 1), exactly as the uncached result does: no column is a read-only view of the cached bytes. A frame that fits one record batch (about 8 MiB) costs one extra copy of its numeric, datetime and categorical columns for this; larger frames do not.
With an uncompressed payload on the File backend (ArrowSerializer(compression=None), or CACHEKIT_ARROW_COMPRESSION=none for serializer="arrow"), a hit reads the stored file through a memory map. Parts of the result that pandas never edits in place can still reference that map: the categories of a numeric or datetime categorical, a CategoricalIndex, and Arrow-backed columns such as pandas 3's default strings. The map, and the disk space of a cache file deleted in the meantime, is released when the result is. Before Python 3.13 the map also holds a file descriptor until then (see Zero-Copy Reads on the File Backend).
Example with polars:
import polars as pl
from cachekit import cache
from cachekit.backends.file import FileBackend, FileBackendConfig
from cachekit.serializers import ArrowSerializer
backend = FileBackend(FileBackendConfig(cache_dir="/tmp/cachekit"))
@cache(serializer=ArrowSerializer(return_format="polars"), backend=backend)
def get_polars_data():
return pl.DataFrame({
"id": [1, 2, 3],
"value": [10.5, 20.3, 30.1]
})ArrowSerializer supports:
pandas.DataFrame(with index preservation)polars.DataFrame(via__arrow_c_stream__interface)dictof arrays (converted to DataFrame)
Not supported. Calling serialize() directly raises TypeError for these. A @cache-decorated call with a backend does not raise: it returns the value, caches nothing, and logs the failure (Troubleshooting → Serialization Failures).
- Scalar values (int, str, float)
- Lists of objects
- Dicts with a number or bool value (
{"id": 1}) - Dicts with a string, bytes or dict value (
{"name": "Alice"},{"user": {"name": "x"}})
Every dict value must be a column Arrow can convert: a list, tuple, NumPy array, pandas Series, pyarrow array or typed memoryview, or any object that implements the Arrow array protocol (__arrow_array__ or __arrow_c_array__), a Mapping included. A memoryview is read as its element type, so a memoryview of bytes stores byte values. Flatten nested dicts into columns first, or use AutoSerializer.
Type checking example:
from cachekit.serializers import ArrowSerializer
serializer = ArrowSerializer()
# Works: DataFrame
df = pd.DataFrame({"a": [1, 2, 3]})
data, meta = serializer.serialize(df)
# Raises TypeError with helpful message
try:
serializer.serialize({"key": "value"})
except TypeError as e:
print(e)
# "... Got a dict that is not convertible to an Arrow table: value for 'key' is str, not a list or array. ..."Measured on a public dataset: one month of the USGS earthquake catalogue (January 2024), 12,535 rows by 13 columns, 3.9 MB in memory. It mixes timezone-aware timestamps, a nullable Int64, categoricals and high-cardinality strings. The slice and its provenance are in the repository at tests/data/. tests/unit/test_real_dataset.py asserts on every pull request that each size stays at most 10% above the figure below, and that every round trip is exact (dtypes, index and names).
| What is cached | Shape | Encoded bytes |
|---|---|---|
| The full frame | 12,535 × 13 | 673,906 |
| A filtered, sorted frame indexed by event id | 2,249 × 12 | 118,938 |
A groupby with named aggregations |
15 × 4 | 4,026 |
A pivot_table of mean magnitude |
15 × 6 | 4,890 |
A full-frame round trip (serialize then deserialize) costs about 60 million instructions on the calling thread, on CPython 3.12.12 and 3.14.3, x86_64 Linux (make perf-ir, paths serializer_arrow_usgs and serializer_auto_usgs; pyarrow runs part of the work on its own threads, which this figure leaves out). A 100-row frame costs about 2 million, so a small frame pays mostly fixed per-call cost. These are instruction counts, not wall time: see Instruction Budgets.
serializer="auto" hands a DataFrame to ArrowSerializer when pyarrow is installed, so it produces the same bytes.
For comprehensive performance analysis including decorator overhead, concurrent access, and encryption impact, see Performance Guide.
A read of a compressed payload (the default) decompresses it into new buffers. An uncompressed, plaintext payload on the File backend can be read without copying it, depending on return_format and the Python version, at the cost of a larger stored payload: see Zero-Copy Reads on the File Backend. Wire backends always copy the payload in.
Writes stream on the File backend. When the cache backend supports streaming writes (File backend only today) and the value is plaintext (no encryption), serialization streams ~8 MiB record batches directly into the cache file instead of materializing the whole Arrow IPC payload in memory first — cutting write peak RSS from ~5.6x to ~2.3x the DataFrame's logical size. Wire backends (Redis, CacheKit Cloud, Memcached) and encrypted values use the buffered path unchanged. See File Backend.
A cache hit memory-maps the cache file instead of reading it into memory when all of these hold:
- the backend is a
FileBackendon a POSIX system, and the stored file is at most 512 MB; - the serializer is cachekit's
ArrowSerializer, asserializer="arrow"or anArrowSerializer(...)instance (a subclass reads the buffered way); - the value is not encrypted, and the cached function is synchronous (hits of an
async deffunction read the file in); return_format="pandas", or Python 3.13 or later for"arrow"and"polars". Arrow and polars results keep the mapping open, and before 3.13 every open mapping also holds a file descriptor, so a few hundred held results would exhaust the process's descriptor limit. On earlier Pythons they read the file in instead. A pandas result keeps the mapping, and before 3.13 its descriptor, only through a column that stays a view of the mapped data.
A compressed payload (the default) is then decompressed out of the mapping into new memory. Only an uncompressed payload, ArrowSerializer(compression=None) or CACHEKIT_ARROW_COMPRESSION=none for serializer="arrow", can stay zero-copy, and what an uncompressed hit costs depends on return_format:
return_format |
Uncompressed File-backend hit |
|---|---|
"arrow" |
Zero-copy. Every buffer of the returned pyarrow.Table points into the mapped cache file, and the hit allocates no heap memory for the data. |
"polars" |
Numeric, boolean and temporal columns are zero-copy. String columns are converted to polars' own layout, which copies them. The frame keeps one chunk per stored record batch (about 8 MiB each); call .rechunk() if you need contiguous columns. |
"pandas" |
One copy: the result is an ordinary, writable pandas DataFrame. |
A zero-copy result keeps the mapping alive for as long as you hold it, and it is unmapped when the result is garbage-collected. Deleting, overwriting or evicting the entry meanwhile does not change the result: cachekit replaces a cache file by renaming a new one over it and deletes by unlinking, so the held result keeps reading the old data. The disk space of a replaced or deleted entry is reclaimed only after the last result that maps it is dropped. Do not truncate or rewrite cache files in place from outside cachekit while results are held: touching a truncated mapping raises SIGBUS.
Uncompressed entries are written with their Arrow data starting on a 64-byte boundary of the cache file (64 bytes is Arrow's recommended alignment), so polars imports fixed-width columns without copying. Entries written by cachekit-py releases before this alignment still read correctly; polars copies their numeric columns.
import tempfile
import pandas as pd
import pyarrow as pa
from cachekit import cache
from cachekit.backends.file import FileBackend, FileBackendConfig
from cachekit.serializers import ArrowSerializer
backend = FileBackend(FileBackendConfig(cache_dir=tempfile.mkdtemp()))
@cache(serializer=ArrowSerializer(return_format="arrow", compression=None), backend=backend, l1_enabled=False)
def load_prices():
return pd.DataFrame({"price": [float(i) for i in range(100_000)]})
load_prices() # miss: streams the uncompressed Arrow IPC into the cache file
pool_before = pa.total_allocated_bytes()
table = load_prices() # hit: a pyarrow.Table over the mapped file on Python 3.13+ (read in before 3.13)
assert table.num_rows == 100_000
assert pa.total_allocated_bytes() == pool_before # no Arrow buffers were allocated for the hitPolars DataFrames are written through the __arrow_c_stream__ interface (Arrow C Data Interface), with no intermediate pandas conversion. Reads with return_format="polars" of an uncompressed payload keep one chunk per stored record batch, so the columns can stay views of the stored bytes; a compressed payload is decompressed into new memory anyway and comes back with contiguous columns. See Zero-Copy Reads on the File Backend for which columns stay zero-copy.
import polars as pl
from cachekit import cache
from cachekit.backends.file import FileBackend, FileBackendConfig
from cachekit.serializers import ArrowSerializer
backend = FileBackend(FileBackendConfig(cache_dir="/tmp/cachekit"))
@cache(serializer=ArrowSerializer(return_format="polars", compression=None), backend=backend)
def get_polars_data():
return pl.DataFrame({
"id": [1, 2, 3],
"value": [10.5, 20.3, 30.1]
})Polars requires polars to be installed:
pip install polars
# or
uv add polarsIf polars is not installed, deserializing with return_format="polars" raises SerializationError ("polars not installed"). Under @cache, every hit then logs that error and runs the function again, as for a corrupt entry.
-
Use return_format="arrow" to skip the pandas conversion, and add
compression=Noneon the File backend for zero-copy hits on Python 3.13+ (details):from cachekit import cache from cachekit.backends.file import FileBackend, FileBackendConfig from cachekit.serializers import ArrowSerializer backend = FileBackend(FileBackendConfig(cache_dir="/tmp/cachekit")) @cache(serializer=ArrowSerializer(return_format="arrow", compression=None), backend=backend) def get_data(): return df # illustrative - df not defined # Result is a pyarrow.Table (no pandas conversion); on Python 3.13+ it is a view of the mapped cache file, no copy table = get_data()
-
Preserve pandas index for efficient round-trips:
# ArrowSerializer automatically preserves pandas index df = pd.DataFrame({"a": [1, 2, 3]}, index=pd.Index([10, 20, 30], name="id")) # Index is preserved through serialization/deserialization
-
Batch similar queries to amortize cache lookup overhead:
from cachekit import cache from cachekit.serializers import ArrowSerializer import pandas as pd @cache(serializer=ArrowSerializer(), backend=backend) # backend as in tip 1 def get_data_batch(date_range): # Return one large DataFrame instead of many small ones return pd.concat([load_day(d) for d in date_range]) # illustrative - load_day not defined
- StandardSerializer — Better choice for DataFrames under 1K rows
- OrjsonSerializer — JSON-optimized for API data
- Encryption Wrapper — Add zero-knowledge encryption to ArrowSerializer
- Performance Guide — Full benchmark comparisons
- Troubleshooting Guide — Serialization error solutions