Dictionary Page Size and Plain Encoding Fallback
When a Parquet writer’s dictionary for a column chunk outgrows the dictionary page size limit, it silently abandons dictionary encoding and writes the rest of that chunk as plain values. The file stays valid, nothing warns you, and a column that was 40 MB last month is 340 MB this month because someone added a region. This page covers detection and prevention, extending dictionary encoding for categorical GIS attributes.
Quick Reference
| Lever | Effect | When to reach for it | Primary Use Case |
|---|---|---|---|
Raise dictionary_pagesize_limit |
Fits a larger dictionary | Cardinality is genuinely moderate | Wide categorical columns |
| Reduce row group size | Fewer distinct values per chunk | Large row groups, scattered values | Mixed-cardinality datasets |
| Sort on the categorical column | Clusters equal values into chunks | Values correlate with a sort key | Region- or class-partitioned data |
| Disable dictionary explicitly | Predictable size, no cliff | Cardinality is inherently huge | Identifiers, free text |
| Assert encoding in CI | Turns a silent regression into a red build | Always | Any pipeline that ships Parquet |
The Cliff
Dictionary encoding replaces each value with an index into a table of distinct values. For a land_use column with forty categories across four million rows, that is forty strings plus four million small integers instead of four million strings — often a fifteen- to twenty-fold reduction before compression even runs.
The writer builds this dictionary incrementally as it fills a column chunk. It has a budget: the dictionary page size limit, one megabyte by default in most writers. If the dictionary is still under budget when the chunk closes, the chunk is dictionary-encoded. If the dictionary crosses the limit partway through, the writer discards the strategy for that chunk and writes plain values from that point on.
The behaviour is entirely reasonable and entirely invisible. Plain encoding is a legal encoding; the file passes every validator; readers handle it transparently. What changes is size, and only size — which is why this is discovered by looking at a storage bill rather than by looking at an error log.
The second important point is that cardinality is counted per row group, not per file. That makes the fallback a function of layout as much as of data: the same column, in the same file, encodes differently depending on how the rows were ordered.
Preventing and Detecting It
# Requires: pyarrow>=16, geopandas>=1.0 (Python 3.10+)
from __future__ import annotations
from dataclasses import dataclass
import pyarrow as pa
import pyarrow.compute as pc
import pyarrow.parquet as pq
DEFAULT_DICT_LIMIT = 1 << 20 # 1 MiB, the common writer default
@dataclass(frozen=True)
class ColumnPlan:
name: str
distinct_per_group: int
estimated_dict_bytes: int
recommended_limit: int
verdict: str
def plan_dictionary(
table: pa.Table, column: str, *, row_group_rows: int
) -> ColumnPlan:
"""Estimate whether this column will survive as a dictionary."""
if column not in table.column_names:
raise KeyError(f"{column!r} is not in the table")
if row_group_rows <= 0:
raise ValueError("row_group_rows must be positive")
# Cardinality within one row group is what the writer's budget sees.
sample = table.slice(0, min(row_group_rows, table.num_rows))
distinct = pc.count_distinct(sample[column]).as_py()
values = pc.unique(sample[column])
dict_bytes = int(sum(len(v.as_py() or "") + 4 for v in values))
if dict_bytes > DEFAULT_DICT_LIMIT:
headroom = int(dict_bytes * 1.5)
verdict = (
"WILL FALL BACK at the default limit — raise the limit, shrink the "
"row group, or sort so equal values cluster"
)
elif dict_bytes > DEFAULT_DICT_LIMIT * 0.6:
headroom = DEFAULT_DICT_LIMIT * 2
verdict = "close to the limit — one new category could push it over"
else:
headroom = DEFAULT_DICT_LIMIT
verdict = "comfortably dictionary-encoded"
return ColumnPlan(column, distinct, dict_bytes, headroom, verdict)
def assert_dictionary_encoded(path: str, expected: tuple[str, ...]) -> None:
"""Fail loudly if any column that should be a dictionary is not.
This is the check that turns a silent size regression into a red build.
"""
meta = pq.read_metadata(path)
names = meta.schema.names
offenders: list[str] = []
for group_index in range(meta.num_row_groups):
group = meta.row_group(group_index)
for column_index in range(group.num_columns):
column = group.column(column_index)
name = names[column_index]
if name not in expected:
continue
encodings = {str(e) for e in column.encodings}
if not any("DICTIONARY" in e or "RLE_DICTIONARY" in e for e in encodings):
offenders.append(f"{name} (row group {group_index}): {sorted(encodings)}")
if offenders:
raise AssertionError(
"dictionary encoding was abandoned for:\n " + "\n ".join(offenders)
)
Validation
Inspect the encodings the writer actually chose. A size regression without this check is untraceable; with it, the cause is named.
# Requires: pyarrow>=16 — what encoding did each chunk really get?
import pyarrow.parquet as pq
meta = pq.read_metadata("parcels.parquet")
names = meta.schema.names
for g in range(min(meta.num_row_groups, 3)):
group = meta.row_group(g)
for c in range(group.num_columns):
col = group.column(c)
print(f"rg{g} {names[c]:<24} {sorted(str(e) for e in col.encodings)} "
f"{col.total_compressed_size / 1e6:7.1f} MB")
Expected healthy output: every categorical column shows RLE_DICTIONARY in its encoding list and a compressed size an order of magnitude below the equivalent plain column. A categorical column showing only PLAIN and RLE has fallen back.
Edge Cases and Caveats
A column that is not actually categorical. Free-text descriptions and per-feature identifiers have cardinality equal to row count, so no dictionary ever fits and raising the limit only wastes writer memory. Disable the dictionary explicitly for those columns so the encoding is a decision rather than an accident, and so the size is stable rather than sitting on a cliff edge.
Sorting for the dictionary versus sorting for space. Sorting on a categorical column improves its encoding but competes with the space-filling-curve sort that makes bbox skipping work. Where they conflict, the spatial sort usually wins because it affects query cost rather than storage cost — and a hierarchical sort, region then Hilbert, often satisfies both.
Cardinality that grows over time. A column comfortably under the limit today can cross it when a new region, class, or vendor code appears, and the regression will look like a mysterious doubling of the nightly output. The assert_dictionary_encoded check exists precisely so that this arrives as a failed build rather than as a question about the storage bill three months later.
Frequently Asked Questions
What is dictionary fallback in Parquet and why does it happen silently?
A writer builds a dictionary of distinct values for a column chunk and stores small integer indices instead of the values themselves. If the dictionary grows past the configured page size limit while writing that chunk, the writer abandons the dictionary and re-encodes the remainder as plain values. Nothing errors, because the file is still perfectly valid — plain encoding is legal. The only visible sign is that the column is suddenly several times larger than it was in the previous run.
Why does cardinality per row group matter more than cardinality overall?
Because the dictionary is built per column chunk, and a column chunk covers exactly one row group. A column with 4,000 distinct values nationally may hold only 40 within any one region-sorted row group, which dictionary-encodes beautifully. The same column in insertion order scatters all 4,000 into every row group and may exceed the limit in each. The layout decision therefore changes the encoding without changing the data.
Should I just raise the dictionary page size limit?
It is the quick fix and often the right one, since a larger limit costs only the memory to hold the dictionary while writing. But it treats the symptom: a column whose per-row-group cardinality is genuinely in the hundreds of thousands is not categorical and will not compress well however it is encoded. Check whether the column is a free-text field or an identifier that has been mistaken for a category before raising the limit.
Related
- Dictionary Encoding for Categorical GIS Attributes — parent guide: what dictionary encoding buys and where it applies
- Row Group Sizing Strategies for Parquet — the size that decides per-chunk cardinality
- Parquet Page Index and Bloom Filters for Spatial Queries — the structure that replaces a dictionary for high-cardinality lookups
- CI/CD Validation Hooks for GeoParquet Conversion — where the encoding assertion belongs in the pipeline
← Back to Dictionary Encoding for Categorical GIS Attributes