pgColumnar builds from one source tree on PostgreSQL 15, 16, 17, 18, and 19. Every test suite runs on all five majors. PostgreSQL 13 and 14 still build but are out of the tested matrix.
Two behaviors depend on the major:
ALTER TABLE ... SET ACCESS METHODexists on PostgreSQL 15 and later. On 13 and 14,pgcolumnar.alter_table_set_access_methodbuilds a new table, copies rows, and swaps names. This preserves columns, defaults, constraints, and indexes, but not the original relation's OID or its dependent objects such as views and foreign keys.- The read stream prefetch path (
pgcolumnar.enable_read_stream) is effective on PostgreSQL 17 and later. On earlier majors the setting has no effect.
The Arrow and Parquet import and export functions run on little-endian hosts only. The rest of the extension runs on any architecture PostgreSQL supports.
- Columnar storage is built for append-mostly data. Updates and deletes are
supported, but they mark rows rather than rewriting data, and the space is
reclaimed only by
pgcolumnar.vacuum. - Point lookups are slower than heap, though far less so than they were. A fetch by item pointer locates the row's group and decodes only the columns the executor asks for, reusing the decoded group for the rest of the statement, so the cost no longer scales with the table's width or with the row's position in its group. Heap still wins a single-row fetch outright. Bloom filters speed up an equality scan by skipping row groups but do not help a fetch by item pointer.
- Bulk
UPDATEandDELETEreached by index are no longer proportional to rows times row group size. They still cost several times what heap costs, because each changed row is marked and rewritten rather than updated in place.
ANALYZE collects column statistics for a columnar table: null fraction,
distinct counts, most-common values, histograms and correlation, the same set it
collects for a heap table. Predicates are estimated from the data.
Correlation is worth calling out because it is the statistic that makes
pgcolumnar.vacuum_sorted and Z-order clustering legible to the planner: a table
stored sorted on a key reports a correlation near 1 for that column, and the
planner can then price a range scan over it correctly.
The row count the planner uses does not come from ANALYZE at all. It is derived
from row-group metadata, so it is accurate whether or not the table has been
analyzed. pg_class.reltuples runs a few percent low after ANALYZE, because
blocks that hold no row-group data (the metapage, and space reserved but not yet
written) count as visited while offering no rows; the planner does not use that
figure for columnar tables.
ANALYZE samples rows through the fetch path, so its cost grows with the number
and width of columns rather than only with row count. On a wide table it can take
a long time, and autoanalyze runs it unprompted; see
issue #171, which also covers
a point-lookup plan regression that appears once statistics exist. On a table
where that matters, consider ALTER TABLE ... ALTER COLUMN ... SET STATISTICS 0
for columns no predicate uses.
TABLESAMPLE is unsupported and says so: it raises an error rather than
returning no rows.
VACUUM FULLandCLUSTERare not supported on a columnar table; the copy-for-cluster path raises an error. Usepgcolumnar.vacuumorpgcolumnar.vacuum_fullinstead.pgcolumnar.vacuumalways rewrites the whole relation into full row groups. It accepts astripe_countargument for interface compatibility but performs the full rewrite. Because it renumbers rows, it rebuilds the table's indexes.- Row numbers are reserved a whole row group at a time, so a row group flushed
with fewer than
stripe_row_limitrows leaves a gap in the row-number space. Row numbers need only be unique and stable, so the gap is harmless.
An index-only scan uses the columnar visibility-map fork, which lazy VACUUM
populates. A row group is reported all-visible only once its inserting
transaction precedes the oldest snapshot horizon and it has no deletes, and any
later write clears the bit. Recently loaded data is served by a snapshot-checked
fetch until autovacuum or an explicit VACUUM marks it. Turn the feature off with
pgcolumnar.enable_index_only_scan = off.
Projections are additional sorted copies, so they add write and storage cost
proportional to the number of projections, and they are rebuilt by
pgcolumnar.vacuum. The planner uses a projection only when it covers every
referenced column (no system columns or whole-row references) and its leading sort
column is restricted; other queries scan the base. A projection added to a
populated table is back-filled under ShareLock, which blocks concurrent writes
for the build, like non-concurrent CREATE INDEX. Turn projection scans off with
pgcolumnar.enable_projection_scan = off.
- Concurrent deletes or updates to rows in the same row group serialize on that row group's row-mask entry: a second writer waits for the first to commit, re-reads the committed mask, and merges its bits, so both sets of delete marks survive. Writes to different row groups proceed concurrently.
- Concurrent inserts of the same unique key are serialized so the conflict is
always caught. Before a freshly inserted row reaches the uniqueness check, the
access method takes a transaction-scoped advisory lock keyed by the row's unique
key. Equal keys hash to the same lock, consistent with the index's equality, so
numeric1.0and1.00,citextcase differences, and collation-equal text serialize correctly. Keys hash into a bounded number of buckets per index (pgcolumnar.unique_lock_buckets, default 128); unrelated keys sharing a bucket are over-serialized, never under-serialized. Unique, immediate, valid indexes are covered, including multi-column, partial, and expression indexes. An index whose operator class cannot be matched to its key type's default equality, or whose key type has no hash support, falls back to a single per-index lock. A genuine same-key conflict can surface as a deadlock abort rather than aunique_violation; both reject the duplicate. Turn the serialization off withpgcolumnar.enable_unique_insert_lock = off.
- Stale index entries left by deletes and updates are filtered on fetch and
reclaimed by
REINDEX, not removed opportunistically. CREATE INDEX CONCURRENTLY(the concurrent validate path) and partial block-range index builds are not supported.
pgcolumnar.import_arrow and pgcolumnar.import_parquet maintain every index on
the target and enforce unique and exclusion constraints, so an import cannot
reach a state an ordinary INSERT would refuse.
One difference from ordinary DML remains: a deferrable unique constraint is checked as each row is inserted rather than deferred to commit. An import that would transiently violate uniqueness partway through, and be consistent by the end, is rejected where ordinary DML would accept it. Enforcing early is over-strict rather than unsound. Tracked as issue #168.
The vectorized aggregate path covers the single-relation, ungrouped
SELECT agg(col) FROM t [WHERE ...] shape only, and only when every aggregate,
column type, and filter clause is supported: count (including count(*) and
count(col)), sum and avg over smallint and integer columns, and min
and max over any type with a default ordering, with WHERE clauses that are
conjunctions of simple column op const comparisons. Anything else (sum or
avg over bigint, numeric, or floating point; ordered-set and string
aggregates; DISTINCT-qualified aggregates; GROUP BY; HAVING; non-simple
filters; joins; whole-row or system column references) falls back to the scalar
plan and stays correct.
Chunk-group skipping from a pushed-down filter is applied only when the comparison's collation matches the column's own collation, the collation the stored minimum and maximum were ordered under. A differently collated comparison is still applied as a filter but does not drive skipping, so results never depend on whether the filter was pushed down.
- Physical replication and physical backups (
pg_basebackup, snapshots) include columnar tables, which are WAL-logged relations. pg_dumpandpg_restorehandle columnar tables. The target server must have the extension installed and preloaded.- Logical decoding reads heap-tuple WAL records. Changes to columnar tables are not emitted through logical decoding, so logical replication does not carry them. Use physical replication for columnar tables.
The import and export functions require superuser and run on little-endian hosts. They support one-dimensional arrays and composite types built from the scalar types below, with nulls at every level. Multi-dimensional arrays and types not listed are rejected.
| Type | Arrow export | Parquet export | Arrow import | Parquet import |
|---|---|---|---|---|
int2, int4, int8 |
yes | yes | yes | yes |
float4, float8 |
yes | yes | yes | yes |
bool |
yes | yes | yes | yes |
text, varchar |
yes | yes | yes | yes |
bytea |
yes | yes | yes | yes |
date, time, timestamp, timestamptz |
yes | yes | yes | yes |
uuid |
yes | yes | yes | yes |
numeric |
yes | yes (numeric(p,s), p <= 38) |
yes | yes (DECIMAL only) |
json, jsonb |
yes | yes | yes | no |
| one-dimensional array of the above | yes | yes | yes | yes |
| composite of the above | yes | yes | yes | yes |
uuid is imported from a 16-byte fixed-length binary column, and numeric from
a DECIMAL column stored as fixed or variable big-endian bytes with precision up
to 38, or from an INT32 or INT64 holding the unscaled integer, which is how
writers store small precisions.
numeric needs a declared precision for a Parquet round trip. The exporter
writes DECIMAL only for a column declared numeric(p,s) with p up to 38; a
numeric column with no precision, or one with p above 38, is exported as
text, and a text column cannot be imported back into numeric. Declare
numeric(p,s) with p up to 38 when the file has to read back into a numeric
column. Arrow export and import carry numeric in either form.
json and jsonb can be exported to Parquet and read back with other tools, but
pgColumnar does not currently import them; they are supported end to end through
Arrow.
For the native table format, lz4 and zstd are available only when the
extension was built with the corresponding system libraries. When a codec is not
built in, a request for it falls back to a codec that is present. pglz and
none are always available.
When reading external Parquet files, the reader decodes uncompressed, Snappy, GZIP, ZSTD, and LZ4_RAW pages. GZIP requires a build with zlib, and ZSTD and LZ4_RAW require the same libraries as the native codecs; a page whose codec was not built in fails with a clean decode error. LZO, BROTLI, and the deprecated Hadoop-framed LZ4 (codec 5, as distinct from LZ4_RAW) are not read.
The read-in-place surface (read_parquet, parquet_schema, and the
pgcolumnar_parquet foreign-data wrapper) has these limits:
- Reads are superuser only and run on little-endian hosts, as import and export do, since they read a server-side path.
- A
paththat is a directory reads the*.parquetfiles at any depth below it, descending into subdirectories. Entries whose name begins with_or.are skipped, directories and files alike, which is the convention Spark and Hive write:_SUCCESSbeside the data and in-progress task output under_temporary. A path named explicitly is still read whatever it is called. A directory reached through a symbolic link is not descended, because a link to an ancestor would make the walk endless; a symbolic link to a file is still followed. Nesting deeper than 32 levels raises rather than reading part of the tree. - Hive-style partitioning is available on the foreign-data wrapper only, through
the
partition_columnstable option. The columns are declared, not inferred from the tree, andread_parquethas no equivalent. A value is taken from the directory name after percent-decoding, so a value written asa%3Dbreads asa=b, and__HIVE_DEFAULT_PARTITION__, the marker Hive and Spark write for a null partition value, reads as NULL rather than as that string. A file that does not carry a directory component for every declared column raises rather than producing rows with nulls in the partition columns. - Only the directory components between the declared path and the file are read
as partition values, so a component above the path, or a file whose own name
looks like
col=value, does not set a column. - A predicate that reads only partition columns prunes files, unless it contains a volatile function. Pruning decides a clause once per file, which matches what the executor would decide per row only when the clause is a function of the partition values alone; a volatile call is not, so such a clause is left to the executor and prunes nothing. Stable and immutable functions still prune.
parquet_schemadescribes the first file of a directory or glob, assuming the set is uniform. The read paths still bind every file against the declared columns, so a mismatched file raises rather than returning wrong rows.- A
TIMESTAMPcolumn with nanosecond precision is advised asbigint, which is exact; declaring ittimestampreads it with the sub-microsecond digits truncated. - A file is read as it is needed rather than loaded whole: the footer is held for the duration of the scan, and pages are read one at a time. Peak memory for the raw file data is one page, not one file, so file size is not a limit. What does scale with the data is the decoded form of one row group for the columns the query reads, since a row group is decoded before its rows are produced. A file written with very large row groups therefore costs more memory than the same data written with smaller ones.
- The column definition list, or a foreign table's column list, must cover every leaf column in the file. A shorter list is an error rather than a projection.
Row-group skipping is narrower than the general statement that a group is skipped when its statistics exclude the predicate. A scan that skips nothing still returns correct rows; these are the conditions under which it can skip at all:
- The clause must be
column op constantwith a btree comparison operator. A parameterized qual, such as one inside a PL/pgSQL function or a generic plan fromPREPARE, does not drive skipping. This is deliberate: the skip set is computed once when the scan starts and reused across rescans. - The column must be stored as a Parquet INT32, INT64, FLOAT, or DOUBLE. Text,
bytea, uuid, and boolean columns are filtered but never skipped, whatever their
statistics. A
numericcolumn follows its storage: one written as an INT32 or INT64 DECIMAL does skip, one written as a byte-array DECIMAL does not. - The constant's type must match the column's type exactly. A cross-type
comparison such as
ts >= DATE '2026-01-01'against atimestampcolumn, orbigint_col > 5::int, does not skip. - The row group's statistics must carry both a minimum and a maximum, and the interval must not be inverted. An unsigned Parquet column straddling the sign boundary, or one narrowed into a smaller PostgreSQL type, decodes to an interval that is not trusted for skipping.
The Row Groups Skipped counter in EXPLAIN ANALYZE reports what was actually
skipped.