How-to guides¶
Task-focused recipes for each pgColumnar feature. Each recipe ends with a short tuning note, and a permissions note where one applies. For the advice behind these tuning notes, collected by decision rather than by task, see the Best practices guide. For full function signatures see the SQL reference. For every server setting see the Configuration reference.
Load pgcolumnar in shared_preload_libraries before you begin. It installs the
planner and executor hooks that every backend needs.
Create a columnar table¶
Create a new table with the access method, or convert an existing heap table.
-- new table
CREATE TABLE events (id bigint, ts timestamptz, region text, amount numeric)
USING pgcolumnar;
-- convert an existing table (heap to columnar, or back)
SELECT pgcolumnar.alter_table_set_access_method('events', 'pgcolumnar');
On PostgreSQL 15 and later the conversion runs ALTER TABLE ... SET ACCESS
METHOD in place and keeps the table identity and dependents. On 13 and 14 it
copies through a new table and swaps the names, so the original OID and its
dependent objects are not preserved.
Tuning. Columnar storage rewards wide scans over a few columns. Keep small,
highly selective point-lookup tables as heap. Put analytic and append-heavy
tables on pgcolumnar.
Load data efficiently¶
COPY and multi-row INSERT both work. A bulk load writes best in large
transactions, because each transaction seals its own row groups.
Load a local file in parallel with a pool of background writers.
SELECT pgcolumnar.parallel_copy('events', '/data/events.csv'); -- workers auto
SELECT pgcolumnar.parallel_copy('events', '/data/events.csv', 8);
Tuning. Many small commits produce many small row groups, which read less
efficiently. Run pgcolumnar.vacuum after a trickle load to combine them.
Permissions. The caller of parallel_copy needs the pg_read_server_files
role.
Choose compression and encoding¶
Each column is encoded automatically (run-length, frame-of-reference,
bit-packing, delta, delta-of-delta, Gorilla XOR, dictionary, and FSST for text).
A block codec then compresses the encoded chunk. The codec is zstd by default.
-- per table
SELECT pgcolumnar.set_options('events', compression => 'zstd', compression_level => 6);
SELECT pgcolumnar.set_options('events', encode_effort => 'fast');
-- session default for new chunks
SET pgcolumnar.compression = 'lz4';
The codec is one of none, pglz, lz4, or zstd. compression_level
applies to zstd and ranges from 1 to 22, default 3. encode_effort is full
by default, or fast to skip the costly FSST search on a text-heavy load.
Tuning. Use zstd for the best ratio and lz4 for the fastest
decompression. Raise compression_level toward its maximum of 22 for cold data
that is written once and read rarely. Set encode_effort to fast when a text
load is CPU-bound and the ratio matters less. Re-run pgcolumnar.vacuum to
re-encode existing data under new options.
Sort a table to skip more chunk groups¶
pgColumnar records a per-chunk minimum and maximum in a zone map. A scan skips a chunk group when its filter cannot match that range. Sorting the table on the columns you filter tightens those ranges, so more groups are skipped.
-- one-shot ascending sort on a key
SELECT pgcolumnar.vacuum_sorted('events', 'region', 'ts');
-- declare a key, then re-apply it later with no arguments
SELECT pgcolumnar.set_options('events', sort_by => ARRAY['region','ts']);
SELECT pgcolumnar.vacuum_sorted('events');
-- Z-order (Morton) clustering over several numeric columns at once
SELECT pgcolumnar.cluster('events', 'customer_id', 'amount');
vacuum_sorted sorts ascending and tightens the first column most. cluster
uses a Z-order curve, so filters on more than one of its columns all skip more
groups. cluster takes numeric columns only.
Tuning. Sort on the column your selective queries filter on. A sort is a
trade-off: it groups one dimension tightly while spreading the others. For a live
table use pgcolumnar.recluster, which re-establishes the same Z-order under a
weaker lock so reads and writes continue. Read pgcolumnar.sort_status to see how
much order remains, and re-sort when it decays.
SELECT pgcolumnar.recluster('events', 'customer_id', 'amount'); -- online
SELECT * FROM pgcolumnar.sort_status('events');
Skip more chunk groups on equality filters¶
A bloom filter skips a chunk group when an equality filter names a value the group
does not hold. It complements the minimum and maximum, which skip range filters
only. Bloom filtering is on by default (pgcolumnar.enable_bloom_filter).
Tuning. It works best on a clustered or sorted column, because equal values
then sit in few chunk groups. Read Columnar Chunk Groups Removed by Filter to
confirm the skip. A scattered high-cardinality column gains little. Turn the
feature off with SET pgcolumnar.enable_bloom_filter = off to compare.
Add a projection for a second sort order¶
A table has one physical sort order. A projection stores a column subset a second time under a different sort key, so a second access pattern also prunes well. The planner reads the projection instead of the base table when the projection covers every column the query needs and gives a lower cost.
SELECT pgcolumnar.add_projection(
'events', 'events_by_customer',
columns => ARRAY['customer_id', 'amount', 'ts'],
sort_key => ARRAY['customer_id']);
EXPLAIN SELECT sum(amount) FROM events WHERE customer_id = 42;
Adding a projection fills it from the existing rows. Later inserts write to the
base table and to every projection, so each projection adds write cost. Drop one
with pgcolumnar.drop_projection('events', 'events_by_customer').
Tuning. Add a projection only for a hot access pattern the base sort order
serves poorly. Confirm the plan shows Columnar Projection. Keep the column list
minimal, because a projection that misses one queried column is never used. See
Projections in the administration guide for detail.
Add indexes and use index-only scans¶
A columnar table supports B-tree and other index types. An index helps a highly selective point lookup that no chunk-group skip can serve. An index-only scan answers a query from the index alone when the chunk groups it reads are all visible.
CREATE INDEX ON events (order_id);
VACUUM events;
EXPLAIN (ANALYZE) SELECT order_id FROM events WHERE order_id = 9001;
Tuning. Keep VACUUM current, because any write clears the all-visible mark
and forces a fetch from the row data. pgcolumnar.enable_index_only_scan and
pgcolumnar.enable_index_fetch_penalty shape when the planner prefers an index
over a scan. Prefer a sort key or a projection for a range query. Reserve an index
for a selective point lookup.
Reclaim space after deletes and updates¶
A delete or update marks rows dead. Space returns when you compact.
SELECT pgcolumnar.compact('events'); -- retire fully-dead groups
SELECT pgcolumnar.compact_rewrite('events', 0.3); -- rewrite groups >= 30% dead
SELECT pgcolumnar.vacuum('events'); -- combine small groups + reclaim
SELECT pgcolumnar.truncate('events'); -- return end blocks to the OS
compact and compact_rewrite hold only ShareUpdateExclusiveLock, so they run
against a live table. compact_rewrite rewrites groups whose dead fraction is at
least min_deleted_fraction (default 0.2), and max_groups caps how many one
call rewrites (0 means no cap). vacuum rewrites the whole relation.
Tuning. Prefer compact and compact_rewrite for online reclaim. Reserve
vacuum for a full reorganization window. Cap compact_rewrite with max_groups
to bound each call and keep it incremental.
Keep tables optimized automatically¶
A background daemon can compact and recluster tables on a schedule. The threshold values below are examples, not the defaults.
-- postgresql.conf
pgcolumnar.autovacuum = on
pgcolumnar.autovacuum_naptime = '60s'
pgcolumnar.autovacuum_compact_threshold = 0.2
pgcolumnar.autovacuum_recluster_threshold = 0.1
Tuning. Lower the thresholds to keep tables tighter at the cost of more background work. The daemon calls the online operations only, so it does not take an exclusive lock. A table whose clustering is already intact reclusters as a fast no-op, so the daemon does not churn storage.
Read and write Parquet¶
Read a Parquet file directly, inspect its schema, or load it into a table.
SELECT * FROM pgcolumnar.read_parquet('/data/events.parquet')
AS t(id bigint, ts timestamptz, region text);
SELECT * FROM pgcolumnar.parquet_schema('/data/events.parquet');
SELECT pgcolumnar.import_parquet('events', '/data/events.parquet');
Export a table to one file, or in parallel to a directory of files.
SELECT pgcolumnar.export_parquet('events', '/data/events.parquet');
SELECT pgcolumnar.parallel_export_parquet('events', '/data/events_out', 8);
Expose a Parquet file, directory, or Hive layout as a foreign table.
CREATE SERVER pq FOREIGN DATA WRAPPER pgcolumnar_parquet;
CREATE FOREIGN TABLE events_parquet (id bigint, ts timestamptz, region text)
SERVER pq OPTIONS (path '/data/events', partition_columns 'region');
parallel_export_parquet writes one part-NNNN.parquet file per worker into the
directory, and read_parquet or the foreign table reads the directory back as one
relation.
Tuning. Project only the columns you need, because the reader reads only those
columns. A predicate on a partition_columns column removes whole files before
they open. EXPLAIN (ANALYZE) reports Files Pruned and the row groups read and
skipped.
Permissions. Reading needs the pg_read_server_files role, and
parallel_export_parquet needs pg_write_server_files.
Import and export Arrow¶
The Arrow IPC file format works the same way as Parquet.
SELECT pgcolumnar.import_arrow('events', '/data/events.arrow');
SELECT pgcolumnar.export_arrow('events', '/data/events.arrow');
Tuning. Use Arrow to exchange data with an in-process analytic engine without a Parquet encode step. Use Parquet for durable, compressed files.
Use object storage¶
Every path that accepts a local path also accepts an s3://, http://, or
https:// URL. Credentials come from the server process environment.
SELECT * FROM pgcolumnar.read_parquet('s3://bucket/events.parquet')
AS t(id bigint, region text);
SELECT pgcolumnar.export_parquet('events', 's3://bucket/events.parquet');
Set the endpoint allow-list first, because it gates every remote scheme.
-- postgresql.conf (a superuser setting)
pgcolumnar.objstore_allowed_endpoints = 's3.amazonaws.com, minio.internal'
The environment supplies AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
AWS_SESSION_TOKEN, AWS_REGION, and AWS_ENDPOINT_URL. A host that resolves to
a link-local or instance-metadata address is refused even when it is listed.
Tuning. Raise pgcolumnar.objstore_part_size to use larger multipart parts
over a high-latency network. Choose pgcolumnar.objstore_s3_addressing to match
your provider's path or virtual-host style. Keep the allow-list as small as your
buckets require.
Query an Apache Iceberg table¶
Read an Iceberg table at its current snapshot from a metadata path.
SELECT region, sum(amount) FROM pgcolumnar.iceberg_scan(
'/warehouse/db/events/metadata/00042.metadata.json')
AS t(id bigint, region text, amount int)
GROUP BY region;
SELECT * FROM pgcolumnar.iceberg_current_snapshot('.../00042.metadata.json');
SELECT * FROM pgcolumnar.iceberg_data_files('.../00042.metadata.json');
The reader resolves each output column to a schema field id, so a file written
before a column rename still reads. It applies position deletes, equality
deletes, and format-version 3 deletion vectors. The metadata path may be a local
path or an s3://, http://, or https:// URL.
Tuning. Select only the columns you need. Read from object storage under the same allow-list as every other remote access.
Permissions. The function requires the pg_read_server_files role.
Query an Iceberg REST catalog¶
Name a table by catalog, namespace, and table instead of a metadata path.
SELECT * FROM pgcolumnar.iceberg_rest_scan(
'https://catalog.example.com', 'analytics', 'events')
AS t(id bigint, region text, amount int);
SELECT * FROM pgcolumnar.iceberg_rest_namespaces('https://catalog.example.com');
SELECT * FROM pgcolumnar.iceberg_rest_tables('https://catalog.example.com', 'analytics');
For per-role credentials, name a foreign server instead of a URI. The server
holds the catalog URI. The current role's user mapping holds the bearer token,
kept in pg_user_mapping where it is not world-readable. On a multi-warehouse
catalog, add a warehouse server option; it is sent on the config request.
CREATE SERVER cat FOREIGN DATA WRAPPER pgcolumnar_iceberg_catalog
OPTIONS (catalog_uri 'https://catalog.example.com', warehouse 'analytics_wh');
CREATE USER MAPPING FOR analyst SERVER cat OPTIONS (token 's3cr3t');
SELECT * FROM pgcolumnar.iceberg_rest_scan('cat', 'analytics', 'events')
AS t(id bigint, region text, amount int);
A user mapping may carry OAuth2 client credentials rather than a static token. The catalog then mints a short-lived bearer.
CREATE USER MAPPING FOR analyst SERVER cat OPTIONS (
oauth_client_id 'app', oauth_client_secret 's3cr3t', oauth_scope 'catalog');
Tuning. A catalog can vend short-lived storage credentials in its reply.
iceberg_rest_scan then reads the table files with those credentials, not the
server environment. The client secret travels in the request body, never a URL or
a log line.
Prune Iceberg data files with the foreign-data wrapper¶
iceberg_scan receives no query predicate, so it cannot skip files. The
foreign-data wrapper gives Iceberg a predicate-bearing scan that prunes whole
files.
CREATE SERVER ice FOREIGN DATA WRAPPER pgcolumnar_iceberg;
CREATE FOREIGN TABLE events (id bigint, region text, amount int)
SERVER ice OPTIONS (metadata_path '/warehouse/db/events/metadata/v3.metadata.json');
-- reads only the matching files; EXPLAIN ANALYZE shows "Files Pruned"
SELECT sum(amount) FROM events WHERE region = 'eu';
Partition pruning covers identity, bucket[N], truncate[W], and the temporal
transforms. It prunes year, month, day, and hour on a timestamp or
timestamptz column, and year, month, and day on a date column. Metrics
pruning covers integer and boolean columns by their stored minimum and maximum.
Pruning never changes the rows returned. A predicate the wrapper cannot decide
reads the file and returns the same rows.
Tuning. Filter on a partition column to remove whole files. Filter on an
integer or boolean column to prune by metrics. Confirm the effect with
EXPLAIN (ANALYZE), which reports Files Pruned. A predicate on an unsupported
type still returns correct rows, only without the pruning.
Tune concurrent writes¶
pgColumnar serializes two conflicting writes so neither is lost. A concurrent
insert of the same unique key, and a concurrent UPDATE or DELETE of the same
row, each take a transaction-scoped advisory lock. The losing writer gets a
retryable error, which the application repeats.
# postgresql.conf (both bucket counts are read at server start only)
pgcolumnar.row_lock_buckets = 4096
pgcolumnar.unique_lock_buckets = 512
Tuning. pgcolumnar.enable_row_update_lock and
pgcolumnar.enable_unique_insert_lock are on by default and should stay on for
correctness. Raise row_lock_buckets or unique_lock_buckets for a workload with
many concurrent single-row writes, so fewer unrelated rows share a bucket. Retry a
serialization_failure (SQLSTATE 40001) in the application. See Concurrency in the
limitations guide.
Vectorize a GROUP BY aggregate¶
The vectorized aggregate path computes an ungrouped aggregate over decoded
vectors. An opt-in setting extends it to GROUP BY.
SET pgcolumnar.enable_group_vectorization = on;
EXPLAIN (ANALYZE) SELECT region, sum(amount) FROM events GROUP BY region;
Tuning. Turn it on for a GROUP BY with a bounded number of distinct groups.
The plan then shows Columnar Vectorized Group Keys. A query that exceeds
pgcolumnar.groupagg_max_groups (default 1000000) raises an error, so leave the
setting off for high-cardinality grouping.
Count rows quickly¶
An unfiltered count(*) reads the per-chunk-group row counts from metadata and
decodes no column data.
Tuning. A filter, or a table with deleted rows, forces a fold over the surviving rows and decodes the filtered columns. Keep the filter on a sorted or bloomed column so chunk-group skipping removes most groups first.
Measure and introspect¶
Inspect physical layout, sort quality, and query plans.
SELECT * FROM pgcolumnar.stats('events'); -- per-row-group rows, dead rows, size
SELECT * FROM pgcolumnar.sort_status('events'); -- sorted vs appended groups
SELECT pgcolumnar.analyze('events'); -- refresh planner statistics
EXPLAIN (ANALYZE) SELECT sum(amount) FROM events WHERE region = 'eu';
EXPLAIN (ANALYZE) on a columnar scan reports Columnar Pushed-Down Filters,
Columnar Usable Skip Predicates, and Columnar Chunk Groups Removed by Filter.
Tuning. Read Columnar Chunk Groups Removed by Filter to confirm a filter
skips data. A low removal count on a selective filter means the sort key does not
match the query. Re-sort on the filtered column, then check the count again.
Benchmark your own workload¶
Reference numbers live in the benchmarks guide. Measure your own tables the same way.
Tuning. Warm the cache with one run and time the next. Read the Columnar
counters in the plan to confirm the query took the path you measure, on both the
columnar table and any row-store baseline. Compare a columnar table against a heap
table of the same rows, not against a different query. Load enough rows to fill the
row groups, because a small table hides the skipping and decode effects.