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 size cliff when a dictionary crosses its page limit Compressed column chunk size plotted against the number of distinct values within one row group. The line stays low and rises gently while the dictionary fits inside the page size limit, then jumps almost vertically at the limit as the writer abandons dictionary encoding and switches to plain values, settling on a much higher and flatter line. A vertical marker shows the default one megabyte limit. Two annotations note that nothing errors at the jump and that the only symptom is size. Compressed column chunk size against distinct values per row group dictionary page limit dictionary encoded indices, not values plain encoded every value written in full no error · no warning · valid file distinct values within one row group → chunk size → The jump is roughly 8× on a typical string column — and it happens the day someone adds a region.

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

python
# 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.

python
# 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.

Sorting on the categorical column changes what the writer's budget sees The same column of four thousand distinct region codes across four row groups. In insertion order, values are scattered so each row group contains nearly all four thousand distinct values and each dictionary exceeds the page limit, causing fallback in every chunk. Sorted on the region column, each row group contains only the few hundred codes belonging to its span, every dictionary fits comfortably, and every chunk stays dictionary-encoded. The data is identical in both cases. Insertion order — 4,000 distinct in every group row group 0 — 3,980 distinct · FALLBACK row group 1 — 3,994 distinct · FALLBACK row group 2 — 3,987 distinct · FALLBACK row group 3 — 3,991 distinct · FALLBACK column: 341 MB compressed every chunk wrote values in full Sorted on region — a few hundred per group row group 0 — 214 distinct · dictionary row group 1 — 189 distinct · dictionary row group 2 — 233 distinct · dictionary row group 3 — 201 distinct · dictionary column: 42 MB compressed identical data, identical writer settings The sort that makes bbox statistics useful also keeps categorical columns dictionary-encoded. Deciding per column rather than per file A schema of six columns classified by cardinality within one row group. Land use, status and district have tens or hundreds of distinct values and dictionary-encode extremely well. Survey date has thousands and is marginal. Feature identifier and free-text description have cardinality equal to the row count, so no dictionary can ever fit and the encoding should be disabled explicitly rather than left to fall back silently. Per-column cardinality decides the encoding, not a file-wide flag land_use — 6 dictionary, comfortably status — 4 dictionary, comfortably district — 214 dictionary, comfortably survey_date — 3,180 marginal — one growth from falling back uprn / description — 4.2 M disable the dictionary explicitly

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.


← Back to Dictionary Encoding for Categorical GIS Attributes