Skip to content
Query.Farm
Talk with Us

Column statistics

How a worker reports per-column statistics — min/max values, null counts, distinct counts — so DuckDB’s query optimizer can make cost-based decisions: eliminate unnecessary scans, improve join ordering, and push down spatial filters.

  1. The worker declares supports_column_statistics=True on tables that provide statistics
  2. When DuckDB plans a query, it calls the catalog_table_column_statistics_get RPC
  3. The worker returns per-column statistics (min, max, null flags, distinct count)
  4. DuckDB caches the result based on the worker’s specified TTL
  5. The optimizer uses the statistics for filter elimination, cardinality estimation, etc.
-- With statistics: optimizer knows id max=10, eliminates entire scan
EXPLAIN SELECT * FROM mydb.data.departments WHERE id > 100;
-- Physical Plan: EMPTY_RESULT

-- Without statistics: optimizer must scan and filter
EXPLAIN SELECT * FROM mydb.data.departments WHERE id > 100;
-- Physical Plan: FILTER → VGI_TABLE_SCAN

The simplest approach: add a statistics dict to your Table descriptor. Types are auto-inferred from the table’s column schema.

from vgi.catalog import Table, Schema, Catalog
from vgi.catalog.descriptors import ColumnStatisticsInput

catalog = Catalog(
  name="mydb",
  schemas=[
      Schema(
          name="data",
          tables=[
              Table(
                  name="products",
                  columns=pa.schema([
                      ("id", pa.int64()),
                      ("name", pa.string()),
                      ("price", pa.float64()),
                  ]),
                  statistics={
                      "id": ColumnStatisticsInput(min=1, max=10000, has_null=False, distinct_count=10000),
                      "name": ColumnStatisticsInput(min="Anvil", max="Zebra Tape", distinct_count=5000),
                      "price": ColumnStatisticsInput(min=0.99, max=999.99, has_null=False, distinct_count=800),
                  },
                  statistics_cache_max_age_seconds=3600,  # Cache for 1 hour
              ),
          ],
      ),
  ],
)
FieldTypeDefaultDescription
minPython value or pa.ScalarNoneMinimum value (auto-converted to PyArrow scalar using column type)
maxPython value or pa.ScalarNoneMaximum value
has_nullboolTrueWhether the column contains NULL values
has_not_nullboolTrueWhether the column contains non-NULL values
distinct_countint | NoneNoneApproximate count of distinct values
contains_unicodebool | NoneNoneString columns only: contains non-ASCII characters
max_string_lengthint | NoneNoneString columns only: maximum byte length

Values can be plain Python literals (int, float, str) which are auto-converted using the column’s Arrow type, or explicit pa.Scalar values for precise control:

# Plain Python values — types inferred from schema
ColumnStatisticsInput(min=1, max=100)

# Explicit PyArrow scalars — used as-is
ColumnStatisticsInput(min=pa.scalar(1, pa.int32()), max=pa.scalar(100, pa.int32()))

statistics_cache_max_age_seconds controls how long DuckDB caches the statistics before making another RPC call:

ValueBehavior
NoneCache forever (default for static data)
0Never cache — re-fetch on every query
NCache for N seconds

For workers that proxy data from a DuckDB database, use the statistics_from_duckdb helper to extract real statistics:

import duckdb
from vgi.catalog.duckdb_statistics import statistics_from_duckdb

conn = duckdb.connect("my_data.duckdb")
stats = statistics_from_duckdb(conn, "products")

Table(
  name="products",
  columns=conn.execute("SELECT * FROM products LIMIT 0").to_arrow_table().schema,
  statistics=stats,
  statistics_cache_max_age_seconds=3600,
)

The helper queries min(), max(), approx_count_distinct(), and null counts per column using DuckDB’s Arrow API, returning properly typed pa.Scalar values. Since 0.16.1 it collects every column in a single scan (with a small Arrow batch size) rather than one pass per column, so the cost no longer scales with table width.

Column TypeStrategy
GeometryComputes spatial bounding box via ST_XMin/ST_XMax/ST_YMin/ST_YMax, handles XY/XYZ/XYM/XYZM vertex types
List / ArrayUses list_min()/list_max() for child element bounds, wraps in single-element lists
Fixed-size ArraySame as List, with type converted to variable-length list
StructStandard min()/max() (lexicographic), FromConstant+Merge produces per-field child stats
MapStandard min()/max() on underlying key-value list structure
Nested Listslist_min()/list_max() naturally peels one nesting layer per level

For computed or live statistics, override table_column_statistics_get() on your CatalogInterface:

from vgi.catalog.catalog_interface import CatalogInterface, TableColumnStatisticsResult
from vgi.catalog.duckdb_statistics import column_statistics_from_duckdb

class MyCatalog(CatalogInterface):
  def table_column_statistics_get(
      self, *, attach_opaque_data, transaction_opaque_data, schema_name, name,
  ) -> TableColumnStatisticsResult | None:
      conn = self._get_connection(attach_opaque_data)
      return TableColumnStatisticsResult(
          statistics=column_statistics_from_duckdb(conn, name, schema_name=schema_name),
          cache_max_age_seconds=60,  # Re-fetch every minute
      )

column_statistics_from_duckdb() returns list[ColumnStatistics] with fully resolved PyArrow scalars — ready to wrap in TableColumnStatisticsResult.

Use the vgi_table_statistics() SQL function to inspect what statistics DuckDB has for a VGI table:

SELECT * FROM vgi_table_statistics('mydb', 'data', 'products');
column_namecolumn_typeminmaxhas_nullhas_not_nulldistinct_count
idBIGINT110000falsetrue10000
nameVARCHARAnvilZebra Tafalsetrue5000
priceDOUBLE0.99999.99falsetrue800

Notes:

  • The min and max columns use a DuckDB UNION type — each column’s value is in its native type
  • String min/max are truncated to 8 bytes (DuckDB’s internal StringStats limit)
  • Geometry columns show the bounding box extent: BOX(xmin ymin, xmax ymax)
  • Tables without supports_column_statistics=True return zero rows

Statistics are transmitted via the catalog_table_column_statistics_get RPC method:

Request: standard catalog params (attach_opaque_data, schema_name, name, transaction_opaque_data)

Response: single RecordBatch with N rows (one per column):

FieldArrow TypeDescription
column_nameutf8Column name
minsparse_union<…>Minimum value (union children are distinct column types)
maxsparse_union<…>Maximum value
has_nullboolColumn contains NULLs
has_not_nullboolColumn contains non-NULLs
distinct_countint64 (nullable)Approximate distinct count
contains_unicodebool (nullable)String columns only
max_string_lengthuint64 (nullable)String columns only

Cache TTL is carried as IPC batch custom_metadata with key cache_max_age_seconds.

serialize_column_statistics() had no inverse, so any non-DuckDB client holding the bytes had to re-implement the sparse-union layout to read them. Since 0.28.0 there is deserialize_column_statistics(), plus a one-call convenience on the client:

stats = client.table_column_statistics(schema_name="main", name="widgets")

Unwrapping the union correctly needs both levels checked: a min/max cell is valid while wrapping a null child, which is what an unknown statistic for that row looks like. Treating a valid cell as a known value is the mistake this function exists to prevent.

Statistics are opt-in at two levels:

  1. Catalog level: CatalogAttachResult.supports_column_statistics — global gate. If False, DuckDB never calls the statistics RPC.
  2. Table level: TableInfo.supports_column_statistics — per-table opt-in. Mixed catalogs can have some tables with stats and others without.

When using the Table descriptor, both flags are auto-derived from whether the statistics dict is non-empty.