SQL reference¶
Every function is in the pgcolumnar schema. Types are shown as in the function
signature. For server settings, see Configuration reference.
Table management¶
pgcolumnar.alter_table_set_access_method(t text, method text)¶
Converts a table to another access method, for example from the default heap to
pgcolumnar or back.
On PostgreSQL 15 and later this runs ALTER TABLE ... SET ACCESS METHOD, which
rewrites the table in place and preserves its identity and dependents. PostgreSQL 13 and 14 do not have that command. On those two versions, the
function makes a second table with LIKE ... INCLUDING ALL. It then copies each
row through the target method and exchanges the names. On those two majors the conversion does not preserve the original table's
OID or objects that depend on it, such as views and foreign keys.
pgcolumnar.set_options(...) and pgcolumnar.reset_options(...)¶
Set or reset per-table storage options (row group and vector row limits,
compression codec and level, encode effort, and the declared sort_by key). See
Configuration reference.
sort_by name[] declares a physical sort key, applied by
pgcolumnar.vacuum_sorted
with no explicit columns. It is not auto-maintained; rows inserted after a sort
append in insertion order, so re-run vacuum_sorted to re-establish it, like
PostgreSQL CLUSTER. Column names must exist and cannot be virtual generated
columns.
ttl_column name and ttl_interval interval declare a retention. They are read
only by pgcolumnar.expire.
Declaring a retention does not delete anything on its own. expire runs when
you call it, or when the maintenance daemon calls it with
pgcolumnar.autovacuum_expire on. Both are needed: either one alone means no
retention.
ttl_interval must be a positive interval. Zero and negative intervals raise
22023. A negative interval would put the cutoff in the future, so expire
would drop rows that are still inside their retention.
SELECT pgcolumnar.set_options('events', sort_by => ARRAY['customer_id','ts']);
SELECT pgcolumnar.reset_options('events', sort_by => true); -- clear it
-- declare a retention; nothing is deleted until expire runs
SELECT pgcolumnar.set_options('events', ttl_column => 'ts',
ttl_interval => '90 days');
pgcolumnar.approx_count_distinct(value anyelement) returns bigint¶
An aggregate: the approximate number of distinct non-NULL values, from a
HyperLogLog of 2048 registers. The standard error is about 2.3%, so most answers
fall within 5% of the exact count(DISTINCT value). It runs on any table, heap
or columnar, and in parallel plans, where it returns the serial answer exactly.
The value's type needs an extended hash function, and a text value a
deterministic collation; anything else is refused with SQLSTATE 0A000. Its hash
and registers are those of the sketches a column named in set_options(t,
sketch_columns => ...) stores per row group. A query with no WHERE and no
GROUP BY is answered from those sketches, unless another aggregate in it needs
the rows. Each row group's sketch is merged, and only a group with deleted rows,
or one written before the column was named, is read. The answer is the same as
reading every row. EXPLAIN ANALYZE shows Columnar Sketch Groups Folded and
Columnar Sketch Groups Scanned.
pgcolumnar.get_storage_id(rel regclass) returns bigint¶
Returns the internal storage identifier of a columnar table. Used to join the
pgcolumnar catalog tables. Most users read pgcolumnar.stats
instead.
Maintenance¶
pgcolumnar.vacuum(tablename regclass, stripe_count int DEFAULT 0)¶
Compacts a columnar table by combining small row groups and reclaiming space held by rows that were deleted or updated. Use it after bulk deletes or updates, or after many small load transactions have produced many small row groups.
stripe_count is not supported and a non-zero value raises an error. The
argument is retained so existing calls that pass the default keep working. It was
documented as bounding how many row groups are combined in one call. It was never
read, and every call rewrote the whole relation regardless of the value.
To bound the work, use
pgcolumnar.compact_rewrite,
whose max_groups does limit how many row groups are rewritten.
pgcolumnar.vacuum_sorted(tablename regclass, VARIADIC sort_columns name[])¶
Compacts a columnar table and stores its rows sorted ascending (NULLS LAST) on
the given columns. Sorted storage makes the per-chunk minimum and
maximum values tight on the sort columns, and it stops them overlapping.
Equality filters and range filters on those columns therefore skip more chunk
groups. Any column with a btree ordering works, and this includes text. The
Z-order cluster() takes numeric columns only. Use it for a segment
key (e.g. customer_id, hostname) whose values are scattered in insertion
order but are often filtered on.
With no columns, it applies the sort_by key that set_options declared for
the table. A bare CLUSTER re-applies a remembered index in the same way. It
raises an error if the table declares no key:
SELECT pgcolumnar.vacuum_sorted('events', 'customer_id'); -- explicit
SELECT pgcolumnar.set_options('events', sort_by => ARRAY['customer_id']);
SELECT pgcolumnar.vacuum_sorted('events'); -- declared key
Re-running vacuum_sorted with the same key on a table that is still in that
order is a fast no-op. It skips the rewrite on four conditions. The recorded
key matches. The recorded kind is a sort and not a Z-order. The sorted run
already covers every row group. No row group holds a deleted row.
That last condition is part of the gate because vacuum_sorted both orders the
rows and reclaims deleted-row space. A table that is in order but holds deleted
rows is rewritten, so the space is still reclaimed. Deletes, inserts, a
different key, or a table arranged by cluster() all fall through to the full
rewrite.
A sort key is a trade and not a free gain. A sort by a segment key puts the rows of each segment together, so a filter on that key skips most groups. It also spreads each other dimension across every group.
For time-series data, a sort by (segment_key, time) keeps time in order inside
each segment. A query that filters on time alone, across all segments, then
prunes less than the natural time order permits.
Sort by the key that your selective queries filter on. This is a one-shot
reorder, and no operation maintains it. Read
pgcolumnar.sort_status to measure how
much of the order remains.
pgcolumnar.cluster(tablename regclass, VARIADIC columns name[])¶
Rewrites a columnar table with its rows ordered by a Z-order (Morton)
space-filling curve on the columns that you give. vacuum_sorted sorts in
ascending order. Thus it makes the minimum and maximum of its first column
tighter. Z-order makes each clustered column tighter at the same time. Range
filters and point filters on more than one column then skip more vectors and more
chunk groups. The results do not change. Only the physical order changes.
Holds AccessExclusiveLock for the duration, like PostgreSQL's own CLUSTER
and VACUUM FULL, because it rewrites the relation and swaps its file. Reads and
writes on the table block until it completes. Use it for an initial bulk
reorganisation; use pgcolumnar.recluster
to reorder a live table without an exclusive lock.
pgcolumnar.cluster_hilbert(tablename regclass, VARIADIC columns name[])¶
The same reorganisation on the Hilbert curve instead of the Z-order one. Take it
when the clustered columns carry range filters. The Hilbert index has no jumps at
a bit boundary. Keys that are close in the data therefore stay close in storage,
and a range filter reads fewer chunk groups. Everything else matches cluster:
the same arguments, the same refusals, the same AccessExclusiveLock.
The curve is sticky. The table records which curve it was laid on, and
pgcolumnar.sort_status reports it as sorted_kind. Once a table is on the
Hilbert curve, plain cluster and recluster on the same key maintain that
curve rather than converting it back. vacuum_sorted leaves the table alone,
and the maintenance daemon re-clusters it with Hilbert. To switch curves, name
the other verb, or recluster on a different key.
pgcolumnar.recluster(tablename regclass, VARIADIC columns name[]) returns bigint¶
The online counterpart to cluster. Re-establishes the same Z-order clustering
over the given columns, but under ShareUpdateExclusiveLock, so concurrent reads
and writes continue instead of blocking. Returns the number of row groups
reclustered.
Re-running recluster with the same key on a table whose clustering is intact is
a fast no-op. The function records the key it last established. It returns 0
without rewriting anything when the recorded key matches, the kind is Z-order,
and the existing sorted run already covers every row group. This is what lets the
maintenance daemon call it on a schedule without churning storage.
On a table laid on the Hilbert curve over the key you name, recluster
maintains that curve. It does not convert the table to Z-order.
pgcolumnar.recluster_hilbert(tablename regclass, VARIADIC columns name[]) returns bigint¶
The online counterpart to cluster_hilbert, and the way to move a Z-ordered
table onto the Hilbert curve. Same arguments, same lock and same return value as
recluster; it re-establishes Hilbert clustering rather than Z-order, and
records the curve it applied.
pgcolumnar.compact(tablename regclass) returns bigint¶
Retires row groups that are fully deleted, dropping their metadata so scans skip
them. Holds only ShareUpdateExclusiveLock, so it runs against a live table.
Returns the number of groups retired.
A group counts as fully deleted only when no transaction can still see its
rows. While a transaction in the same database is older than the DELETE,
compact retires none of those groups and can return 0. Such a transaction
holds a snapshot or a transaction ID from before the DELETE committed. A
prepared transaction or a replication slot holds rows back the same way. Call
compact again after that transaction ends.
pgcolumnar.expire(tablename regclass) returns bigint¶
Drops row groups whose rows are all older than the retention declared by
set_options. Returns the number of groups dropped. Holds only
ShareUpdateExclusiveLock, so it runs against a live table.
This deletes rows. It runs when you call it, or when the maintenance daemon
calls it with both pgcolumnar.autovacuum and pgcolumnar.autovacuum_expire
on. The second is off by default. No other operation applies a retention, and
VACUUM never does.
Each group it drops is recorded in pgcolumnar.retired_group, in the same
transaction. An incremental rollup reads that record to keep the summary of rows
that retention dropped (#1294). No other operation writes there.
It reads no data. A row group records the maximum value of each of its columns. A group whose maximum is older than the cutoff holds no row still inside the retention. Its metadata is dropped without decoding anything.
A group is kept whole or dropped whole. A group holding rows on both sides of the
cutoff is kept, and its expired rows stay until every row in that group has
expired. Retention is therefore approximate at the group boundary, and it errs
toward keeping data. A smaller stripe_row_limit narrows the boundary.
The retention column must be date, timestamp or timestamptz. The table
must have both ttl_column and ttl_interval declared, or the function raises
an error rather than reporting that it did nothing.
On a date column the cutoff is the day containing now() - ttl_interval. A row
dated exactly on that day is kept, so a date retention keeps a row up to a day
longer than the interval asks. That is the same direction the group boundary errs
in, and for the same reason. It applies to a whole-day interval too, because the
cutoff is measured from the current instant rather than from midnight.
The cutoff is the calling session's date, not the server's. expire converts
the current instant through the session's TimeZone. Two sessions can therefore
retire different groups. Take a three-day retention, with the server at
2026-09-22 03:21+00. A session in UTC cuts at 2026-09-19. One in
Pacific/Midway cuts at 2026-09-18, a day earlier. A timestamptz column does not have this behaviour,
because it carries its own zone. Set TimeZone explicitly in the session that
calls expire if you need the boundary to be the same every time.
expire works on whole row groups and never rewrites them, so it drops a group
only when every row in it is past the retention. A group that straddles the
cutoff stays whole, and rows older than the retention survive in it.
It decides from row-group metadata alone on a table with no deletes. On a table
that has any deletes it reads the retention column of a candidate group. The
recorded NULL count is written once, so a NULL that has since been deleted
must not go on pinning the group. Nothing is rewritten either way.
One live NULL in the retention column pins its entire row group. A NULL has
no age, so the group cannot be known to be wholly expired. Deleting the NULL
rows releases the group, and the next expire can drop it. This is stronger
than the straddling rule above. A straddling group is released once its newest
row ages past the cutoff. A group holding a NULL never is. Keep the retention
column NOT NULL if you want expire to reclaim the space.
SELECT pgcolumnar.set_options('events', ttl_column => 'ts',
ttl_interval => '90 days');
SELECT pgcolumnar.expire('events'); -- returns groups dropped
pgcolumnar.compact_rewrite(tablename regclass, min_deleted_fraction float8 DEFAULT 0.2, max_groups int DEFAULT 0) returns bigint¶
Rewrites partially-deleted row groups, those whose deleted fraction is at least
min_deleted_fraction, to drop their dead rows and reclaim the space, under
ShareUpdateExclusiveLock. max_groups caps how many groups a single call
rewrites; 0 means no cap. Returns the number of groups rewritten.
min_deleted_fraction must be a number from 0 to 1, both ends included. The
function rejects NaN, a negative value and a value above 1. Each raises SQLSTATE
22023, invalid_parameter_value. NaN needs a test of its own because it
compares false against every bound. An accepted NaN would match no row group, so
the call would reclaim nothing and still report success.
pgcolumnar.truncate(tablename regclass) returns bigint¶
Gives the reclaimed blocks at the end of the file back to the operating system.
The function does what it can. It takes AccessExclusiveLock for the short
physical step, but only if the lock is available. If the table is busy, the
function returns 0 and does not wait. It removes only the space that became free
before the oldest-xmin horizon. Gated by pgcolumnar.enable_end_truncation, which is off by
default. Returns the number of blocks truncated.
pgcolumnar.vacuum_full(schema name DEFAULT 'public', sleep_time real DEFAULT 0.0, stripe_count int DEFAULT 0)¶
Runs pgcolumnar.vacuum on every columnar table in a schema. sleep_time is a
pause in seconds between tables.
stripe_count is passed through to pgcolumnar.vacuum and is subject to the same
restriction: a non-zero value raises an error.
pgcolumnar.analyze(rel regclass, columns text[] DEFAULT NULL)¶
Collects planner statistics for named columns of a columnar table. It reads each
column once instead of sampling whole rows. PostgreSQL 18 or later. On 17 and
below it raises an error. It writes through pg_restore_attribute_stats, which does
not exist before 18.
SELECT pgcolumnar.analyze('events', ARRAY['customer_id']); -- one column
SELECT pgcolumnar.analyze('events'); -- every column
This is not a faster ANALYZE. That difference decides whether you want it. It
reads every value of the columns you name. Core samples 30,000 rows, whatever the
size of the table. Measured on 3,000,000 rows across 20 columns:
| time | |
|---|---|
core ANALYZE, all 20 columns |
7,169 ms |
pgcolumnar.analyze(rel, ARRAY['k']), 1 column |
1,092 ms |
pgcolumnar.analyze(rel), all 20 columns |
90,611 ms |
It wins when you want a subset of the columns of a wide table. Core cannot do
that at all. ANALYZE t (k) still materialises whole rows, so naming one column
saves about 6 percent. It loses badly for every column. On this shape the crossover
is two to three columns. Prefer core ANALYZE unless you analyse a few columns of a
wide table.
The extra cost buys exactness. A sample can miss a value held by one row in 500,000. It also collapses range estimates above the largest value it saw. This function reads the column, so the frequencies and the bounds are counted.
Statistics written, for each named column:
| statistic | |
|---|---|
null_frac |
exact, from the same read as the rest |
n_distinct |
exact |
most_common_vals |
exact, and with no significance filter |
most_common_freqs |
exact |
histogram_bounds |
over the rows the most-common list does not hold |
Core applies a significance filter to its most-common list because that list is sampled. This one is counted, so the filter does not apply.
Not written: correlation, and nothing at the level of the relation.
pg_class.reltuples stays at -1 after this function. That looks alarming and is
not. The row estimate of a columnar table comes from the access method, not from the
catalog, so plans still get the right row count. Run core ANALYZE if you want the
catalog populated.
Nothing schedules this. Autovacuum does not call it. The same is true of
pgcolumnar.vacuum,
but the consequence differs. A stale vacuum wastes space. Stale statistics produce
bad plans, and they do it silently. Re-run this function when the data changes, or
keep core ANALYZE scheduled and use this only to sharpen particular columns.
pgcolumnar.stats(rel regclass)¶
Requires SELECT on the table. The function runs with the privileges of the
extension owner, because it reads pgcolumnar's own catalog tables. It checks the
calling role's privilege on the table before it returns anything.
Returns one row per row group, with these columns:
| Column | Type | Meaning |
|---|---|---|
stripeid |
bigint | Row group number within the table. |
fileoffset |
bigint | Byte offset of the row group in the relation file. |
rowcount |
bigint | Rows written into the row group. |
deletedrows |
bigint | Rows in the row group marked deleted. |
chunkcount |
integer | Vectors in the row group. |
datalength |
bigint | On-disk length of the row group in bytes. |
-- total live rows, deleted rows, and size
SELECT sum(rowcount) AS rows,
sum(deletedrows) AS deleted,
pg_size_pretty(sum(datalength)) AS size
FROM pgcolumnar.stats('events');
pgcolumnar.sort_status(rel regclass)¶
Reports how much of a table's sorted order is still in place. Returns one row:
| Column | Type | Meaning |
|---|---|---|
sort_key |
name[] | The clustering key in effect. It is the key the last ordering rewrite recorded, or the sort_by declared by set_options, or NULL. |
sorted_kind |
text | How that key is applied: lexicographic, zorder, or NULL. NULL means the table was never ordered, or was ordered before pgColumnar recorded this. |
total_groups |
bigint | Row groups in the table. |
sorted_groups |
bigint | Row groups written by the last ordering rewrite. |
appended_groups |
bigint | Row groups written after it. |
sorted_rows |
bigint | Rows stored in the sorted groups. |
appended_rows |
bigint | Rows stored in the appended groups. |
pgcolumnar.vacuum_sorted and pgcolumnar.cluster order a table once. Rows
inserted afterwards go in at the end, in insertion order. The sorted part
therefore shrinks in proportion as the table grows. This function measures that
proportion, so you can decide when another sort is worth its cost.
-- what fraction of the table is still in sorted order, and by what arrangement
SELECT sort_key,
sorted_kind,
sorted_rows,
appended_rows,
round(100.0 * sorted_rows / nullif(sorted_rows + appended_rows, 0), 1)
AS percent_sorted
FROM pgcolumnar.sort_status('events');
A table that was never sorted reports zero sorted groups. An unsorted
pgcolumnar.vacuum returns it to that state, because it rewrites the table
without ordering it.
sort_key names the columns, not the arrangement. pgcolumnar.vacuum_sorted
sorts on those columns in order. pgcolumnar.cluster and
pgcolumnar.recluster arrange the same columns on a Z-order curve, which is not
a sort on any one of them.
Read sorted_kind to tell the two apart. Before 1.0-alpha3 this function could
not report it, and the documented way to read it was pgcolumnar.storage. That
table carries no GRANT, so only a superuser could follow that advice.
The row counts are stored rows. Deleted rows stay stored until a maintenance
operation reclaims them, so they are still counted here. Use
pgcolumnar.stats to read the deleted count per
group.
Three limits apply. The online pgcolumnar.recluster records only the part of
its output it can prove is one contiguous ordered run. With no concurrent writer
that is the whole relation. If another session inserts while it runs, it records
less, sometimes much less, and reports the rest as decay. It never reports less
decay than there is. The counts describe where rows are stored, not whether their
values are still in order. An UPDATE stores the new row version at the end,
which counts as appended. The record is internal storage metadata and pg_dump
does not carry it, so a restored table reports no sorted groups until you sort it
again.
pgcolumnar.column_extent(rel regclass, col name)¶
Shows the bound the planner uses for a column's smallest and largest value, when
it estimates how many time buckets a GROUP BY date_trunc(...) or date_bin(...)
produces. Returns one row, or none:
| Column | Type | Meaning |
|---|---|---|
complete |
bool | Always true when a row is returned. |
minimum |
text | The smallest value, printed by the column's type. NULL if the column holds no non-NULL value. |
maximum |
text | The largest value, printed the same way. |
The bound is kept in pgcolumnar.column_extent, and the planner reads it in one
index lookup however many row groups the table has. Writes widen it when they
commit. Every operation that drops or rewrites row groups recomputes it before
committing: expire, compact_rewrite, VACUUM, recluster. So it can be
wider than the data only by rows that are deleted but still stored.
It needs SELECT on the table, and it refuses a table whose row-level security
policies apply to the caller (see limitations).
No row means the planner reads every row group's zone map instead, which gives the same answer at a cost that grows with the table. That happens for:
- a column added after some row groups were written;
- a type with no ordering;
- a table written by an earlier version, until its next recompute.
The values are column data, so the caller needs SELECT on the table.
pgcolumnar.maintenance_due(rel regclass, compact_due_fraction float8 DEFAULT 0.2, recluster_due_fraction float8 DEFAULT 0.05)¶
Reports whether an online maintenance verb is worth running on a table, from its
statistics alone. It takes no lock and rewrites nothing. This is the policy the
pgcolumnar.autovacuum daemon consults on each sweep. You can also call it from a
monitoring query. Returns one row:
| Column | Type | Meaning |
|---|---|---|
total_rows |
bigint | Stored rows, including deleted rows not yet reclaimed. |
deleted_rows |
bigint | Rows deleted but still stored. |
deleted_fraction |
float8 | deleted_rows over total_rows. |
sort_key |
name[] | The recorded clustering key, or NULL. |
appended_groups |
bigint | Row groups written after the last ordering. |
appended_rows |
bigint | Rows in those appended groups. |
appended_fraction |
float8 | Appended rows over the sorted plus appended rows. |
compact_rewrite_due |
boolean | True when deleted_fraction reaches compact_due_fraction. |
recluster_due |
boolean | True when a sorted run exists and appended_fraction reaches recluster_due_fraction. |
recommendation |
text | The verbs to run, comma-separated, or NULL when nothing is due. |
The two thresholds default to the values the daemon uses. Each must be a number
from 0 to 1, both ends included. The function rejects NaN, NULL, a negative
value and a value above 1. Each raises SQLSTATE 22023, the same code and the
same bounds as pgcolumnar.compact_rewrite. An unchecked threshold fails silently
rather than loudly. NaN, NULL or a value above 1 reports nothing as due, which
suppresses maintenance for good. A negative value reports every table as due on
every sweep.
The function is SECURITY DEFINER and checks that the caller may SELECT the
table. A monitoring role that owns the table can therefore call it without
superuser rights.
Projections¶
A projection is a named subset of a table's columns stored a second time, optionally sorted on a key. When a projection covers a query and serves it better than the base table, the planner scans the projection instead. See Administration.
pgcolumnar.add_projection(rel regclass, name text, columns text[], sort_key text[] DEFAULT '{}')¶
Declares a projection on rel named name, storing columns, sorted on
sort_key. When you add the projection, pgColumnar fills it with the rows that exist.
SELECT pgcolumnar.add_projection(
'events', 'events_by_customer',
columns => ARRAY['customer_id', 'amount', 'ts'],
sort_key => ARRAY['customer_id']);
pgcolumnar.drop_projection(rel regclass, name text)¶
Drops a projection and frees its storage. It also removes the declaration, so a later rebuild does not create the projection again.
pgcolumnar.rebuild_projections(rel regclass DEFAULT NULL)¶
Builds each declared projection that has no storage, and returns the number that it built. Give a relation to limit it to one table. Give no argument to cover each table in the database.
pg_dump carries the projection declarations, in
pgcolumnar.projection_declaration, but it cannot carry the projection storage.
Run this after a logical restore. A second run builds nothing, so it is safe to
run at any time.
You no longer need it after a rewrite. TRUNCATE, a rewriting ALTER TABLE,
REFRESH MATERIALIZED VIEW and the maintenance rewrites re-record their
projections themselves.
Two cases still need this function. The first is a logical restore. The second is
a rewrite that does not pass through ProcessUtility, so nothing observes it. A
TRUNCATE replicated to a subscriber is one: the logical replication worker calls
ExecuteTruncateGuts directly. Run this on the subscriber after one.
A third case looks like this function's job and is not. A declaration that names a column the table no longer has is reported by a rewrite as
WARNING: 42703: could not restore projection "p" on "public.t" after rewrite
DETAIL: Its declaration names a column the table no longer has.
Do not run this function for that. It re-runs the same declaration and raises the
same missing-column error. Call pgcolumnar.add_projection() again instead, with
the same projection name and columns the table has, which is what the HINT on
that WARNING tells you.
pgcolumnar.read_projection(rel regclass, name text) and pgcolumnar.reconstruct_via_projection(rel regclass, name text)¶
Return a projection's stored rows as text, for verification. They are for inspection and testing, not for query use.
Import and export¶
These functions read and write Arrow IPC files and Parquet files. export_arrow
writes the IPC stream format; import_arrow reads the stream format and the IPC
file format. They read
and write files on the server host, so a reader needs the pg_read_server_files
role and a writer needs the pg_write_server_files role. Superusers hold both.
The Parquet functions also read from and write to object storage. See
Object storage.
They run on little-endian hosts only. They support scalar column types, one-dimensional
arrays, and composite types, with nulls at every level. The functions refuse multi-dimensional arrays
and types that they do not support. See
Limitations and compatibility.
pgcolumnar.export_arrow(rel regclass, path text) returns bigint¶
Writes the live rows of rel to an Arrow IPC stream file at path. Returns the
number of rows written. The file has no ARROW1 header or footer, so pyarrow
reads it with pyarrow.ipc.open_stream, not open_file. path can be a local
file or an s3:// URL. See Object storage. A record batch
holds at most 16384 rows and at most pgcolumnar.export_group_bytes of buffered
data, 256 MiB by default, unless one row alone is larger. See
Export size limits.
pgcolumnar.export_parquet(rel regclass, path text) returns bigint¶
Writes the live rows of rel to a Parquet file at path. Returns the number of
rows written. path can be a local file or an s3:// URL. See
Object storage. A row group holds at most 65536 rows and at most
pgcolumnar.export_group_bytes of buffered data, 256 MiB by default, unless one row
alone is larger. See Export size limits.
pgcolumnar.export_parquet(rel regclass, path text, server name) returns bigint¶
Writes the file as the two-argument form does, but a remote path is written
with the settings of server. These are the settings that a foreign table of
server reads with, so a foreign table on path can read the file back.
- The endpoint and region come from the server's
endpointandregionoptions. If the server has no such option, the value comes fromAWS_ENDPOINT_URLorAWS_REGIONin the environment, whoever the caller is. A read does the same. - The credentials come from your user mapping on the server, or from its
PUBLICmapping. The environment's keys are used only where a read may use them: see Endpoints and credentials. A role that is not a superuser and has no mapping is refused (28000).
The function refuses these before it writes anything:
- a null
server(22004); - a
serverthat does not exist (42704); - a
serverwhose wrapper does not have pgcolumnar's Parquet handler,pgcolumnar.parquet_fdw_handler(42809). A wrapper made with that handler under another name is accepted, astier_partitionaccepts it (#1963); - a caller without
USAGEonserver(42501).
A local path writes no object and uses no endpoint, region or key. The
function still makes every check above and still looks up your credentials on
the server. So a mapping that holds only half a credential refuses a local
path too (28000).
pgcolumnar.partition_create_ahead(parent regclass, width interval, ahead int) returns int¶
Creates columnar partitions of parent, each width wide, until they reach now
plus ahead widths. Returns the number of partitions created. A second call
creates none. The first new partition starts at the highest existing upper
bound. With no partitions, it starts at the width's boundary that holds now.
parent must be partitioned by RANGE on one timestamp or timestamptz
column. It must have no DEFAULT partition and no partition bounded by
MAXVALUE (55000). A call that would create more than 1000 partitions is
refused before it creates any (22023). The function takes
SHARE UPDATE EXCLUSIVE on the parent, never ACCESS EXCLUSIVE. See
Create time partitions ahead.
pgcolumnar.partition_drop_plan(parent regclass, cutoff timestamptz | timestamp) returns setof text¶
Returns the statements that retire every partition of parent whose upper bound
is at or before cutoff, in range order. Each partition gets
ALTER TABLE ... DETACH PARTITION ... CONCURRENTLY, then a DO block that runs
DROP TABLE, or DROP FOREIGN TABLE for a partition tier_partition moved to
Parquet (the file stays). The block refuses (55000) while the partition is
still attached, so a failed detach is never followed by a drop that locks the
parent. A partition that an interrupted concurrent detach
left pending gets DETACH PARTITION ... FINALIZE instead. A partition that is
itself partitioned is listed whole. The names are schema-qualified. The
function runs nothing: run each statement at top level, for example with
\gexec, because a concurrent detach cannot run inside a function. cutoff
must have the key's type; the other type is refused (42804), and an untyped
literal is a timestamptz. It refuses a parent with the same codes as
partition_create_ahead (55000), and a NULL cutoff (22023).
pgcolumnar.partition_drop_candidates(parent, cutoff_type) holds the checks
the two overloads share. See Drop old time partitions.
pgcolumnar.create_rollup(rollup name, source regclass, ts_column name, width interval, origin timestamptz, group_by name[], agg_functions text[], agg_columns name[], access_method name DEFAULT 'pgcolumnar') returns regclass¶
Creates a rollup of source in the source's schema. A rollup is a view of
date_bin(width, ts_column, origin) buckets per group_by key, with one column
per aggregate. Each aggregate is a function from agg_functions (count,
sum, min, max or avg) applied to the column at the same position in
agg_columns. * goes only with count. ts_column must be timestamptz.
The data is kept in a table named <rollup>__partials, with access method
access_method. The function refuses a width with months, an unknown function,
arrays of different lengths, and a column the source does not have (22023).
See Keep a rollup up to date.
pgcolumnar.refresh_rollup(rollup regclass) returns bigint¶
Brings the rollup up to date in one statement, and returns the number of
source rows it read. It folds the rows of the row groups added to the source
since the last refresh. For each row group deleted from or rewritten since, it
recomputes from the source the buckets that the group's rows fall in, and
nothing else. Runs at READ COMMITTED only (25000).
Row groups that pgcolumnar.expire retired are dropped from the refresh's
record, and the rollup keeps their buckets as history. Refuses, changing
nothing, when a bucket to recompute holds history (0A000), and after a rewrite
of the source (55000).
pgcolumnar.row_tid(row_number bigint) returns tid¶
Returns the item pointer that a columnar table gives the row with this row
number. Row numbers start at 1 (22003 otherwise).
pgcolumnar.rebase_rollup(rollup regclass) returns bigint¶
Records the source's current row groups as folded, without changing the rollup, and returns the number of row groups recorded. It reads every row group to record the time range of its rows, and keeps the rollup's history. Use it after a rewrite of the source, or after a refusal to recompute history, when the rollup is right as it stands.
pgcolumnar.tier_partition(partition regclass, server name, path text, lock_wait interval DEFAULT '10 seconds') returns bigint¶
Moves the columnar partition partition to a Parquet file at path. Then
attaches the file as a foreign table of server. The server's wrapper must
have pgcolumnar's Parquet handler, pgcolumnar.parquet_fdw_handler. That is
the pgcolumnar_parquet wrapper, or a wrapper you made with that handler
under another name (#1963). The foreign table takes the partition's name,
columns (with their collations and generated expressions), owner, bounds,
grants and comment. Returns the number of rows moved.
The function writes the file with the settings of server, as
export_parquet(partition, path, server) does: the server's endpoint and
region, and your user mapping on the server. A mapping that the object store
rejects stops the move before anything is detached, and the rows stay in the
columnar partition.
After the attach, the function counts the rows through the new foreign
partition, as you, before it commits. If that read fails or counts a different
number, the move rolls back and the rows stay in the columnar partition. This
catches a key that can write but not read. The file that the export wrote stays
in place; a retry with the same path overwrites it.
You need both pg_write_server_files and pg_read_server_files, or superuser:
the function writes the file and then reads it. Every role that queries the
parent after the move needs pg_read_server_files and a mapping on server that
can read the file.
The function runs as one transaction, at READ COMMITTED only (25000
otherwise). It holds ACCESS EXCLUSIVE on the parent from the detach until
commit. Every lock waits at most lock_wait (55P03).
The function refuses these:
- a
paththat a foreign table in this database reads (58P02). That is the same path, a path under the table's directory, or a path that matches the table's glob (#1941). A glob is matched bypath_glob_matches, as the reader matches it (#1957). The scheme and the host of an http(s) URL match in any case, ands3a://ors3n://matchs3://. A local path is compared as written, and again in a folded form (#1965, #1959). The folded form reads//as/, drops a.name, and drops a name followed by... In a glob, a name such as\.counts as., becauseglob(3)removes the backslash. So/w//*.parquet,/w/./*.parquet,/w/\./*.parquetand/w/x/../*.parquetrefuse/w/a.parquet. The folded form is the file the kernel opens only when no symbolic link comes before a... When one does, the kernel goes up from the directory the link leads to. The check can then refuse a file the reader does not read, and allow the file it does read. A symbolic link is not resolved, and a relative path is not resolved against the data directory. A remote key is compared as written, because//is part of a key. A new file under a directory a foreign table reads is refused too; docs/how-to.md shows ways around it. Only a wrapper with pgcolumnar's Parquet handler has itspathoption read. For apgcolumnar_icebergforeign table, a path under the table location is refused: the parent of the directory that holdsmetadata_path(#1949). The same two comparisons apply to that location. The Iceberg reader resolves a symbolic link in it, but this check does not. So a path in the directory the link leads to is not refused. The error detail names the foreign table and its path. A control character in either prints as\xHH, such as\x0Afor a newline, so the detail stays on one line (#1960). A backslash before anxprints as\x5C, so a path that holds the characters\x0Adoes not print like a newline. In aWIN1252database the euro sign, byte0x80, prints as written; - a
pathwhen a foreign table's glob is too large to match (54000, seepath_glob_matchesbelow). This is a local glob for a localpath, and ans3://glob with more than 128*for ans3://path. The function cannot tell whether that table readspath, so it refuses. The error detail names the foreign table and its glob, escaped as above (#1974). A table that does readpathis reported as58P02instead. This holds also when its glob is too large as written but not in the folded form. The reader bounds a local glob in its own folded form too, so it reads such a table (see Glob patterns too large to match); - a
DEFAULTpartition (0A000); - a parent with a
UNIQUEindex or primary key (0A000); - a partition that other objects depend on (
2BP01); - a relation that is not a columnar partition (
42809); - a
serverwhose wrapper does not have pgcolumnar's Parquet handler (42809); - a caller that is not a member of both
pg_write_server_filesandpg_read_server_files(42501), before anything is written.
See Move cold partitions to Parquet.
pgcolumnar.path_glob_matches(pattern text, path text) returns boolean¶
Returns true when a foreign table whose path is pattern expands it, as a
glob, to path (#1957). tier_partition uses it to find a file that a
foreign table reads. It matches with the Parquet reader's own code:
- For an
s3://key, the reader'sfnmatch, withFNM_PATHNAMEandFNM_PERIOD. The scheme matches in any case, ands3a://ands3n://matchs3://. - For a local path,
glob(3), as the reader calls it. The function runs it over a tree in memory that holds onlypath, so it reads no directory andpathneed not exist. Apaththat ends in/is a directory; otherwise its last name is a file.
It returns false for a pattern that the reader does not read as a glob:
- a local path without
*,?or[; - an
s3://URL with none of them in its key; - any other remote URL.
A local pattern does not match a remote path.
A path of 4096 bytes (PATH_MAX) or more returns false, because no file
can be opened by such a path.
A pattern that is too large for glibc to match safely raises error 54000
(program_limit_exceeded) instead of an answer:
- a pattern of 4096 bytes or more;
- a local pattern with more than 128
/after its first*,?or[; - an
s3://pattern with more than 128*.
glob(3) uses stack space for each / after the first *, ? or [.
fnmatch uses stack space for each *. A larger pattern could crash the
server. The Parquet reader refuses the same patterns with the same error
(#1968, #1972). It counts the levels of a local pattern in a folded form; see
Glob patterns too large to match.
A local pattern that makes the walk too long also raises error 54000 (#1973).
Each directory in the tree in memory lists . and .., as a real one does,
and a name such as .* matches both. So each level of /.* doubles the
walk. The function stops after 1,000,000 steps of the walk. The number of
steps depends on the glibc glob(3) code, not on the PostgreSQL version.
With glibc 2.43, repeat('/.*', 17) still answers, and repeat('/.*', 18)
is refused after about 60 ms. The function never answers false for a walk it
stopped. The step count starts again at zero for each call, so a refused
pattern does not affect later calls in the same session.
For a local pattern, the function compares each path glob(3) returns with
path, first as spelled and then in a folded form (#1982). The folded form
reads a run of / as one, and drops . names and a name followed by ... So
/w//x matches /w/x*. glob(3) lists . and .. in each directory, and a
name that spells the leading dot matches them, such as .*, .?, .[.] or
\.*. So /w/x/.*/a.parquet matches /w/x/a.parquet, through
/w/x/./a.parquet. A *, ? or [.] alone does not match a leading ., so
/w/x/*/a.parquet does not match /w/x/a.parquet. The directories that the
pattern names before its first *, ?, [ or \ are taken to exist, so
/w/x/sub/.?/a.parquet matches /w/x/a.parquet, through sub/... The
function cannot know which directory a wildcard matched. So when a wildcard
name comes before a name that can match .., it also assumes that a
directory the wildcard matches exists (#1991). It assumes as well that each
name the pattern spells there exists as a directory. So
/w/x/*/.?/a.parquet matches /w/x/a.parquet, which the reader reads when
/w/x holds any directory. Without such a directory, tier_partition then
refuses more than it must. A name such as ?? cannot match .., so
/w/x/*/??/a.parquet does not match /w/x/a.parquet. If the walk with
these assumptions passes 1,000,000 steps, the function answers true. The
folded form does not resolve a symbolic link before a ... So it can match a
file the reader does not open, and tier_partition then refuses more than it
must. An s3:// key is not folded: // and . are part of a key. tier_partition also compares the folded forms of the foreign
table's path and the target. So it refuses what the reader opens under either
spelling, unless a symbolic link comes before a .. (#1965).
SELECT pgcolumnar.path_glob_matches('s3://b/x[a/b]', 's3://b/xa'); -- true
SELECT pgcolumnar.path_glob_matches('/w/x[a/b]', '/w/xa'); -- false
In an s3:// key a class can match /. A local glob is split at every /
first, so there [a and b] are two names. It reads no file, so any role may
call it.
A range such as [a-c] follows the database's LC_COLLATE setting
(datcollate), as in the reader. In en_US.UTF-8, [a-c] matches á; in
C it does not. A database with the ICU provider still uses its
LC_COLLATE, not its ICU locale. Take a range that ends in a collating
symbol, such as [!-[.a.]]. In a UTF-8 locale such as en_US.UTF-8, glibc
can answer it differently from one call to the next. The reader has the same
limit, because it calls the same fnmatch. So the function is VOLATILE,
not IMMUTABLE: do not use it in an index or a generated column. It is
STRICT and PARALLEL SAFE.
pgcolumnar.import_arrow(rel regclass, path text) returns bigint¶
Inserts the rows of an Arrow IPC file at path into the existing table
rel. The column types of the table define the types that the function accepts. Returns the number of
rows inserted.
path is a local file. An object-storage URL is refused with 0A000; see
Object storage. The function checks INSERT on rel first, so
a caller without INSERT gets 42501 for a URL too.
The file can be in the IPC stream format, which export_arrow writes, or in the
IPC file format, which starts and ends with ARROW1. Feather v2 is the IPC file
format. The function reads the stream inside an IPC file and ignores its footer.
A message or body length larger than the rest of the file is refused with
XX001 before any memory is allocated for it.
Temporal columns are read in the unit the file declares, not the unit
export_arrow writes. A date column accepts date32 and date64. A time
column accepts time32 in seconds or milliseconds, and time64 in microseconds
or nanoseconds. A timestamp column accepts any of the four Timestamp units.
Values are converted to PostgreSQL's own units, and a value that cannot be
represented is refused with 22008 rather than stored wrong.
Nanoseconds are narrowed to microseconds, the finest resolution PostgreSQL stores. Narrowing floors, so an instant before 1970 reports the microsecond it falls in. Sub-microsecond precision is lost; nothing else is.
A temporal Arrow type the target column cannot hold is refused with 42804
rather than read. A time64 file does not import into a date column. A file
whose type is not temporal is unaffected.
A nested Arrow type the target column cannot hold is refused with 42804, and
the message names the column (#1932). An array column reads one List level.
A Map reads into an array of a two-field composite. A list of lists, or a
plain column, is refused for an array column. A list, map or struct is refused
where the column, or an array's element type, is a scalar. A large list,
fixed-size list, list view, union or run-end encoded column is refused for every
column type. A file whose offsets run past
its buffers is still refused with XX001.
pgcolumnar.import_parquet(rel regclass, path text) returns bigint¶
Inserts the rows of a Parquet file at path into the existing table rel. The
reader handles uncompressed, Snappy, GZIP, ZSTD, and LZ4_RAW pages, PLAIN and
dictionary encodings, and data-page versions 1 and 2. path may name a single
file, a directory (every *.parquet file below it is imported, at any depth),
or a glob
pattern. Returns the number of rows inserted.
-- round-trip a table through Parquet
SELECT pgcolumnar.export_parquet('events', '/tmp/events.parquet'); -- returns row count
CREATE TABLE events_copy (LIKE events) USING pgcolumnar;
SELECT pgcolumnar.import_parquet('events_copy', '/tmp/events.parquet');
-- import an entire directory of Parquet files
SELECT pgcolumnar.import_parquet('events_copy', '/data/events/');
pgcolumnar.parallel_copy(target regclass, filename text, workers int DEFAULT NULL, dedup boolean DEFAULT false) returns bigint¶
Loads a text file into a columnar table with several background workers at once,
as one atomic operation. Returns the number of rows loaded. The caller needs
membership in the pg_read_server_files role, which superusers hold, and INSERT
on the target. The file uses COPY text format. Each worker runs core COPY over
a byte range of the file, so parse and write behavior match COPY FROM exactly.
filename is a local file. An object-storage URL is refused with 0A000; see
Object storage. The function checks INSERT on target first,
so a caller without INSERT gets 42501 for a URL too.
The target may be one of two kinds:
- A single columnar table. The workers write the one table together. Any record-aligned split of the file is correct, so the file needs no ordering.
- A RANGE-partitioned table whose partitions are columnar. Each worker loads a distinct set of partitions. The file must be sorted ascending by the partition key. The key column may sit anywhere in the row, and its type must be numeric or a date/time type. The function reports an error when the file is not sorted.
The load is atomic. Each worker prepares its transaction rather than committing,
and a coordinator commits them together only if every worker succeeded. A bad
row, a full disk, or a constraint failure in any range rolls the whole load back.
The target keeps its earlier contents. The load runs in background workers, so it
commits on its own. It is not part of the calling transaction, and a caller
ROLLBACK does not undo it. Set max_prepared_transactions above the worker
count, because the load prepares one transaction per worker.
When workers is omitted the function derives a value from the target. For a
partitioned target it lowers workers to the partition count when the count is
smaller.
Refusing a load this table has already taken¶
A load that commits, whose acknowledgement the client never receives, is retried,
and the rows go in twice. Pass dedup => true to refuse the repeat.
With dedup, the function records the SHA-256 of the file after a successful
load, in pgcolumnar.load_fingerprint. A later load of a file with the same
contents into the same table stores nothing, returns 0, and raises a NOTICE
saying why. It does not fail. A file whose contents changed is a different load
and is stored, even at the same path.
dedup is off by default. Discarding rows a caller asked to store is not
ordinary INSERT behavior, so it happens only when asked for, and only on this
function.
Three limits apply.
The fingerprint is recorded after the data commits. A crash between the two
leaves the data stored and unrecorded, so a later retry stores it again. That is
the behavior without dedup and is the safe direction.
Two identical loads running at the same time both store their rows. Each checks
the record before either writes it. dedup refuses a load that follows a
completed one; it does not serialize concurrent loads.
A refused load still does the work. The rows are read, parsed and written, and then discarded without being made visible, because the check happens after every worker has prepared. The alternative costs every load a serial pass over the file before any worker starts. Measured on a 264 MiB file, that pass takes 42% of the load, while the fingerprint as implemented has no measurable cost.
-- refuse a repeat of a load this table has already taken
SELECT pgcolumnar.parallel_copy('events', '/data/events.txt', 8, true);
-- single columnar table, any row order
CREATE TABLE events (id bigint, ts timestamptz, val double precision) USING pgcolumnar;
SELECT pgcolumnar.parallel_copy('events', '/data/events.txt', 8); -- returns row count
-- RANGE-partitioned target, file sorted ascending by the partition key
SELECT pgcolumnar.parallel_copy('events_by_day', '/data/events_sorted.txt', 8);
pgcolumnar.parallel_export_parquet(target regclass, path text, workers int DEFAULT NULL) returns bigint¶
Writes a columnar table to a directory of Parquet files with several read-only
background workers at once. Returns the number of rows written. The caller needs
membership in the pg_write_server_files role, which superusers hold, and SELECT
on the target. The output directory must be empty. It is created if it does not exist. Each worker writes
its own part-NNNN.parquet file, and pgcolumnar.read_parquet reads the whole
directory back as one relation.
The path may also be an s3:// prefix. Each worker writes one
part-NNNN.parquet object under it, exactly as for a local directory. No other
remote scheme is accepted. The export lists its prefix, as the next sentences
explain, and only an s3:// prefix can be listed. So a gs://, http:// or
https:// prefix is refused with SQLSTATE 0A000 before any worker starts, and
nothing is uploaded. See
Object storage for the endpoint and credential rules, which
are the same as for export_parquet. One difference applies to a remote prefix.
The local path is created and checked for emptiness before the export. A remote
prefix is not checked, so a re-run may write into a used prefix. Before it
writes the _SUCCESS marker, the export lists the prefix to remove stale parts
a larger prior run left. Its credentials therefore need list permission
(s3:ListBucket on AWS) as well as write and delete. If the listing fails,
those stale parts can remain, as described below under "A failed listing can
leave a larger earlier run's parts".
The target may be one of two kinds:
- A single columnar table. The workers split it by row-group ranges, so each worker writes a distinct part of the table.
- A partitioned table whose partitions are columnar. Each worker takes a distinct set of partitions and writes one file per partition.
The export is read-only and consistent. The dispatcher exports one snapshot and
every worker imports it. The files together are the committed image of the table
at call time. This holds even when the call runs inside a transaction with
uncommitted rows. There is no coordinator and no shared write state. If any worker
fails, or the statement is cancelled, the dispatcher removes the files it wrote.
It also removes them when every worker has finished but a later step fails:
removing stale parts or writing the _SUCCESS marker. Over an s3:// prefix
the dispatcher deletes the same objects by key. On a cancel or a dispatcher
error, it first waits up to 5 seconds for the stopped workers to exit. It
waits for a local directory and an s3:// prefix alike. A stopped
worker that was committing its part waits for the store's answer. The store can
apply that commit after a DELETE sent meanwhile. Waiting puts
the DELETE after the commit. A local worker that was renaming its finished part
has renamed it before it exits. A worker that is still running after 5 seconds,
such as one waiting on a store that does not answer, can still leave its part.
A worker terminated mid-upload
can leave one incomplete multipart upload that the dispatcher cannot address.
Set a bucket lifecycle rule that expires incomplete multipart uploads to reclaim
it, as Exporting to object storage
recommends.
A failed listing can leave a larger earlier run's parts. The cleanup
above removes a failed run's own parts. So a partial directory is never left for
read_parquet to union, with one exception: a failed listing of a remote prefix.
A key that may write but not list fails the run, and the dispatcher removes the
parts this run wrote. Without the listing, though, the export cannot find the
higher-numbered parts that a larger earlier run left in the prefix, so they
remain. A run of 4 workers followed by a failed run of 2 leaves part-0002 and
part-0003, with no _SUCCESS marker. A later directory or prefix read would
read those stale objects back as if they were a whole table. So check for
_SUCCESS before you read a prefix, and read only a prefix that carries it.
On success the function writes an empty _SUCCESS marker at the destination, the
Hadoop and Spark convention. Its presence means a complete run's output is there;
a failed or cancelled run, whose part files the dispatcher removes, leaves none.
The marker is written last, after every worker has finished, so a directory or
prefix that carries it is a whole export. A remote prefix allows a re-run to
overwrite a used prefix. A smaller re-run there first removes the stale
higher-numbered parts a larger prior run left, so the marker certifies exactly
the parts this run wrote. read_parquet and the foreign-data wrapper skip it
when they read the directory or prefix. They skip any name below it that begins
with an underscore.
When workers is omitted the function derives a value from the target.
-- single columnar table, split across 8 workers
SELECT pgcolumnar.parallel_export_parquet('events', '/data/events_out', 8);
-- read the whole directory back as one relation
SELECT count(*) FROM pgcolumnar.read_parquet('/data/events_out')
AS t(id bigint, ts timestamptz, val double precision);
Reading external Parquet¶
These read a server-side Parquet file in place, without importing it. They
require the pg_read_server_files role, which superusers hold, and operate on
little-endian hosts. In each function, path
can be one of three things. It can be a single file. It can be a directory, and
then the function reads all the *.parquet files below it at any depth as one
relation, in sorted order. It can also be a glob pattern. A local path can be
any of these. An object-storage URL can be an exact key, and an s3:// URL can
also be a prefix or a pattern. A remote prefix ends in /. Every remote read,
of a single key as well as a prefix or pattern, needs the object-store module.
See Object storage.
The readers return each value as the declared column type, including its type
modifier. They apply it as PostgreSQL's input functions do. A numeric(p,s)
column rounds each value to scale s. A value with more than p - s digits
before the point is an error, SQLSTATE 22003 (numeric field overflow). A
varchar(n) column refuses a longer value with SQLSTATE 22001 (value too long),
unless the extra characters are spaces; those it drops. A timestamp(p) or
time(p) column rounds to p fractional digits. This holds for read_parquet,
import_parquet, iceberg_scan and both foreign-data wrappers, and for array
elements and composite fields. So CREATE TABLE AS or INSERT ... SELECT from a
reader never stores a value that breaks the declared type. A row that an Iceberg
delete removes is not checked.
Glob patterns too large to match¶
The readers refuse a glob pattern that is too large for glibc to match
safely, with error 54000 (program_limit_exceeded), before they read
anything (#1968, #1972):
- a pattern of 4096 bytes (
PATH_MAX) or more, measured for a local pattern in its folded form (see below); - a local pattern with more than 128
/after its first*,?or[, counted in its folded form; - an
s3://pattern with more than 128*. The reader refuses it before it lists the prefix.
The reader counts and expands a local pattern in a folded form. In that
form, each run of / becomes one /, and a . name followed by / is
dropped. The kernel
opens the same file by either spelling, so the files read do not change. So
'/w/s[p]' || repeat('/.', 200) || '/a.parquet' has 201 levels as written,
but 1 in the folded form, and the reader reads /w/sp/a.parquet. With
repeat('/.', 5000) the pattern is over 4096 bytes as written but not in
the folded form, and the reader reads it too. Two names are kept as written:
- a
.at the end, or a.that only/follow, because it makes the pattern match only directories. Without it,glob(3)matches a pattern that ends in/against a file, so'/w/s[p]/a.parquet/./'would read/w/sp/a.parquet(#1977 review); - a name followed by
.., because when that name is a symbolic link, the kernel goes up from the directory the link leads to.
A backslash before a / keeps the / from being folded with what follows
it, and a name such as \. is not dropped. So tier_partition, which folds
these as well as a name followed by .., can find fewer levels than the
reader does.
glob(3) and fnmatch use stack space for each such level, and a larger
pattern could crash the server. The limits are those of
path_glob_matches.
The reader checks the pattern before it looks for an object of that exact
name. So an s3:// key that holds more than 128 * cannot be read by name.
The reader also bounds the walk of a local pattern on the filesystem
(#1984). Each real directory lists . and .., and a name such as .*
matches both. So each level of .*/ in a local pattern multiplies the walk.
The reader counts each directory it opens, each name it reads and each
stat as one step. It stops after 1,000,000 steps with error 54000, the
error of path_glob_matches, and reads no file. The count covers every
name the walk reads in every directory the pattern reaches, not only the
names that match. So a glob fails when the directories it walks hold more
than about 1,000,000 names in all. That includes a partitioned tree such as
<tree>/*/*.parquet whose directories are each small: 400 directories of
2,600 names each are refused. Read such a tree as a directory instead. A
directory read finds every *.parquet in its subdirectories and does not
count steps. We measured on PostgreSQL 18 with glibc 2.43. A pattern of 18
levels of .*/ is refused after about 0.25 s. A glob that matches 2000
files among 12,000 names reads every match.
A cancel, a statement_timeout, a transaction_timeout (PostgreSQL 17
and later) or pg_terminate_backend stops the walk. Before, the walk did not stop for any of them: with 15
levels of .*/ it ran for minutes, and only SIGKILL stopped it. An
interrupt that raises no error, such as the one
client_connection_check_interval sets, does not stop the walk; the reader
handles it once the walk ends.
If a cancel arrives while interrupts are held off, the reader cannot process
it, and the walk stops again at once on every attempt. After 100 such
attempts the read fails with error 57014,
could not expand pattern "...": interrupted 100 times.
pgcolumnar.read_parquet(path text) returns setof record¶
Returns the rows of a Parquet file. You must supply a column definition list. It gives the names of the output columns and their types. The reader connects the list to the leaf columns of the file by position. It uses the same rules for type compatibility as the import functions.
The list must contain each leaf column in the file. A list with fewer columns is
an error and not a projection. The read stops and gives a message. The message
contains the number of leaf columns in the file and the number that the target
expands to. Its DETAIL names the file, so a read of a directory, a prefix or a
glob says which of its files does not match. Every other error about a file that
does not match the list has the same DETAIL. The same rule applies to a foreign
table's column definitions. Projection pushdown selects the declared columns
that the reader decodes. This is a separate question from the number of columns
that you must declare. Use
parquet_schema to generate the full list: declare one column for each row it
reports. A composite column covers all the leaves of its struct.
A string cell read into a text or varchar column must be valid in the
server encoding (#1821). A cell with an invalid byte sequence or an embedded
NUL byte stops the read with SQLSTATE 22021, as COPY does. The message names
the Parquet column and the first bad bytes. This applies to the foreign table,
the import functions and iceberg_scan too, because they share the reader.
SELECT * FROM pgcolumnar.read_parquet('/data/events.parquet')
AS t(id int, ts timestamp, amount numeric(12,2));
-- read a whole directory
SELECT count(*) FROM pgcolumnar.read_parquet('/data/events/')
AS t(id int, ts timestamp, amount numeric(12,2));
pgcolumnar.read_parquet(path text, field_ids integer[]) returns setof record¶
Returns the rows of a Parquet file, binding output columns to file columns by
Parquet field id rather than by position. Output column i is bound to the file
column whose field id equals field_ids[i]. The reader decodes only those
columns, in the order given, whatever their order in the file. The array length
must equal the column definition list length. This is the projection form Apache
Iceberg uses (#388). A data file written before a column rename still carries the
old name, and columns are selected by id.
The file must carry field ids. A file written without them is an error that
directs you to the positional form above. Each requested id must match exactly
one scalar column of a compatible type. A compatible type includes Iceberg's two
exact widenings: an integer INT32 column reads as bigint, and a FLOAT
column as double precision. The positional form above does not widen. An
absent id is an error. So is an id that matches more than one column, or an
array or composite output column. An id that belongs to a struct, list or map is
refused as nested, whatever the output column's type (#1788). None is a silent
wrong column. Read the ids a file carries with parquet_schema. It does not
list the ids of a struct's fields; the parquet_schema section below says how
to find them.
-- the file has columns alpha (id 7), beta (id 3), gamma (id 12);
-- read gamma then alpha, by id, in that order
SELECT * FROM pgcolumnar.read_parquet('/data/events.parquet', ARRAY[12, 7])
AS t(g int, a int);
pgcolumnar.parquet_schema(path text) returns table(column_name text, data_type text, nullable bool, field_id int)¶
Reports the top-level columns of a Parquet file and the PostgreSQL type each maps
to, without reading the data. Useful for writing the column definition list for
read_parquet or a foreign table. For a directory or glob it describes the first
file. The type it reports holds every value the column can store. So an unsigned
UINT_32 column reports bigint, and a UINT_64 column reports numeric(20,0).
A millisecond or microsecond timestamp column that holds instants
(isAdjustedToUTC=true, or the older TIMESTAMP_MILLIS or TIMESTAMP_MICROS
type) is reported as timestamp with time zone. A column of wall times
(isAdjustedToUTC=false) is reported as timestamp without time zone. Each
advised type reads the value unchanged. See
Timestamps and time zones.
A legacy INT96 timestamp column, which Spark, Hive and Impala write, is reported
as timestamp with time zone. The reader takes an INT96 value as an instant in
UTC, as Spark and Hive write it. Impala writes wall time instead, so its values
read shifted; see Limitations. A read keeps
whole microseconds and drops the nanosecond digits by rounding down. The column
also reads into timestamp, which gets the wall time in the session time zone.
A value whose nanoseconds fall outside the day, or whose Julian day is outside the
timestamp range, is refused with SQLSTATE 22008. The DETAIL names both the Julian
day and the nanoseconds the file stores.
The rows are the column definition list that read_parquet needs: declare one
column per row, with the reported type. A nested column that read_parquet reads
as one column gets one row under its own name:
- A list of a scalar reports that type's array type, for example
bigint[]. - A struct of scalar fields reports its field list, for example
composite (a bigint, b text). Create a composite type with those fields,CREATE TYPE s_t AS (a bigint, b text), and declare the column ass_t. A field name that needs quotes comes quoted, so the list works as written.
read_parquet reads any other nesting leaf by leaf, so parquet_schema reports
one row for each leaf. A map, a list of structs, a struct of a struct and a struct
that holds a list are examples. Each row is named by the leaf's path in the file,
joined with dots, for example m.key_value.key. Its type is an array type when
the leaf repeats. So a map with string keys and bigint values reports
m.key_value.key as text[] and m.key_value.value as bigint[]. A dotted name
must be quoted in a column definition list, or you can use any other name: the
reader matches columns by position.
Two rows can share a name, for example when the file also has a top-level column
named s.t.list.element. Give one of them another name in your list.
data_type is NULL for a leaf that no column type reads correctly. A leaf under
two repeated levels, such as the elements of a list of lists, is one example.
read_parquet cannot read a file that holds such a leaf. Declared as an array,
the leaf fails with SQLSTATE 42804, because a one-dimensional array would lose
the inner lists. Declared as a scalar, it fails as unexpectedly repeated.
read_parquet binds every column by position, so you cannot leave it out
either. import_parquet and the pgcolumnar_parquet foreign table refuse the
file the same way, even for a query that does not use that column (#1917).
nullable for a nested column says whether the column itself can be NULL. A
NULL list element or struct field does not make it true. On a leaf row it is true
when an OPTIONAL element sits on the leaf's path above its first repeated element.
field_id for a list or struct row is the column's own id. A leaf row carries
the leaf's own id. The ids of a struct's fields do not appear when the struct has
its own row. read_parquet(path, ids) still reads a struct field by its id, as a
scalar column. Read those ids with another Parquet tool, for example pyarrow's
ParquetFile(path).schema.
When a reader refuses a declared column type, the message names the function or
foreign-data wrapper you used, for example
column "c" has type money, which pgcolumnar.read_parquet does not support.
field_id is the Parquet schema field id the column carries. Formats such as
Apache Iceberg use it to select columns by id rather than by name. It is NULL when
the writer emitted no field id. A field id of 0 is a real value, so a NULL and a 0
mean different things.
pgcolumnar.read_avro_manifest(path text) returns table(status int, content int, file_path text, file_format text, record_count bigint, file_size_in_bytes bigint, partition text, sequence_number bigint)¶
Decodes an Apache Iceberg Avro manifest file and reports its data-file entries.
Each row is one entry: the file path, format, row count, byte size, partition,
and the entry's data sequence number. The partition is rendered as name=value
(see How a partition value is shown). The
sequence number is NULL when the entry inherits it from the manifest. That is
the usual case for a freshly written manifest. It is the ordering key for
reading tables with deletes. A position delete applies to data files with a
lower or equal sequence number, the same commit or earlier. An equality delete
applies to a strictly lower one. The caller needs the pg_read_server_files
role, which superusers hold. This is the first step of Iceberg support (#388).
It is a standalone Avro object-container reader, decoded against the schema
embedded in the file, so a v3 manifest reads structurally. It reads a local
file. An object-storage URL is refused with 0A000; see
Object storage. A caller without pg_read_server_files gets
42501 for a URL too. It does not resolve a table's snapshot or apply delete
files. To read a whole table, use iceberg_scan.
SELECT file_path, record_count, partition
FROM pgcolumnar.read_avro_manifest('/data/warehouse/db/events/metadata/abc.avro');
pgcolumnar.read_manifest_list(path text) returns table(manifest_path text, manifest_length bigint, content int, partition_spec_id int, added_files_count int, existing_files_count int, deleted_files_count int, added_rows_count bigint, existing_rows_count bigint, deleted_rows_count bigint, sequence_number bigint, min_sequence_number bigint, added_snapshot_id bigint)¶
Decodes an Apache Iceberg snapshot manifest-list Avro file and reports its
manifest_file entries. Each row names one manifest the snapshot points at. It
carries the manifest's length, content type (0 data, 1 deletes), and partition
spec. It also carries the added, existing, and deleted file and row counts the
writer recorded. The caller needs the pg_read_server_files role, which
superusers hold. This is step two of Iceberg support (#388). It reads the same
Avro object-container format as read_avro_manifest, decoded against the schema
embedded in the file. A v3 manifest list therefore reads structurally. It reads
a local file. An object-storage URL is refused with 0A000; see
Object storage. A caller without pg_read_server_files gets
42501 for a URL too. It does not resolve a table's current snapshot or open the
manifests it names. To read a whole table, use iceberg_scan.
SELECT manifest_path, added_files_count, added_rows_count
FROM pgcolumnar.read_manifest_list('/data/warehouse/db/events/metadata/snap-123.avro');
pgcolumnar.iceberg_current_snapshot(metadata_path text) returns table(snapshot_id bigint, parent_snapshot_id bigint, sequence_number bigint, timestamp_ms bigint, operation text, manifest_list text, schema_id int)¶
Reads an Apache Iceberg table metadata.json and reports the current snapshot
the table declares. The file may be plain or gzip-compressed (*.gz.metadata.json
or *.metadata.json.gz). The current snapshot is the one whose id equals the file's
current-snapshot-id. The row carries its sequence number, commit timestamp,
and operation (append, overwrite, delete, replace). It also carries the
manifest-list file the snapshot points at and its schema id. It returns one row,
or no rows when the table has no current snapshot. The caller needs the
pg_read_server_files role, which superusers hold. The metadata path can be a
local file or an s3://, http:// or https:// URL, as for iceberg_scan.
A remote path is read with the server process environment's credentials. The
function reports the manifest_list path as the file records it, without
opening it. Decode that file with read_manifest_list, or read the table with
iceberg_scan.
SELECT snapshot_id, operation, manifest_list
FROM pgcolumnar.iceberg_current_snapshot('/data/warehouse/db/events/metadata/v3.metadata.json');
pgcolumnar.iceberg_data_files(metadata_path text) returns table(file_path text, file_format text, record_count bigint, partition text)¶
Lists the live data files of an Apache Iceberg table at its current snapshot.
It resolves the current snapshot from metadata.json, reads that snapshot's
manifest list, then each manifest. It returns one row per data-file entry: the
file path, format, row count, and partition rendered as name=value (see
How a partition value is shown). The
caller needs the pg_read_server_files role, which superusers hold. It returns
no rows when the table has no current snapshot.
The absolute paths recorded in the table are rebased onto the table's actual
location. That location is taken from where metadata.json sits. A table copied
to a new directory therefore still reads. A recorded path that points outside
the table location is refused, not read.
A data file that the current snapshot removed is not live, so it is skipped. A copy-on-write delete or a filtered overwrite records the file it replaced this way, as a manifest entry with status 2 (DELETED).
Delete files are refused, not ignored. A snapshot that carries a live delete
file raises an error here. It does not return rows the table says are gone. To
read a table that uses deletes, use iceberg_scan, which applies position,
equality, and deletion-vector deletes. A delete file that the current snapshot
removed is not live, so it is skipped too. A rewrite or compaction that removes
every old delete file leaves a delete manifest that holds only such entries
(#1829); that table lists normally. A delete manifest that holds a live
data-file entry is corrupt, and is refused with SQLSTATE XX001.
How a partition value is shown¶
iceberg_data_files and read_avro_manifest show the partition as text. Each
field is name=value, and fields are joined by commas. The text is always valid
in the database encoding, and each value can be read back exactly:
| Partition value | Shown as | Example |
|---|---|---|
integer, long, and the date, time, timestamp, bucket, truncate and temporal transform results |
the stored integer (days, hours or microseconds for the temporal ones) | id=7, ts_day=19783 |
| boolean | true or false |
flag=true |
| decimal | the exact value, with the column's scale | price=-0.50 |
| float, double | the shortest text that reads back as the same value | x=0.1 |
| binary, fixed, uuid | \x and lowercase hex, as bytea prints it |
b=\xfffe |
| string | the value, with %, ,, =, control characters and DEL written as % and two hex digits |
s=a%2Cb%3Dc |
| NULL | the field name alone, with no = |
s |
An empty string is s=, so it differs from NULL. In a database whose encoding is
not UTF8 or SQL_ASCII, a non-ASCII string is also percent-encoded, byte by byte
of its UTF-8 form (city=caf%C3%A9). So is a string that is not valid UTF-8.
Field names follow the string rule.
SELECT file_path, record_count, partition
FROM pgcolumnar.iceberg_data_files('/data/warehouse/db/events/metadata/v3.metadata.json');
pgcolumnar.iceberg_scan(metadata_path text) returns setof record¶
Reads an Apache Iceberg table at its current snapshot. You supply a column
definition list. Each output column name is resolved to a field id through the
table's current schema. Every live data file is then read, projected by those
ids. Iceberg selects columns by field id. A data file written before a column
was renamed still reads. The name in the file need not match, only the id. The
caller needs the pg_read_server_files role, which superusers hold.
The output column names must be fields of the table's current schema; a name that is not is an error. Matching is case sensitive against the schema, so quote a mixed-case name in the column definition list to preserve its case. Only Parquet data files are read.
The table may live in object storage. A metadata path of s3://, http://, or
https:// reads the metadata, manifests, data files, and delete files from the
endpoint. The endpoint must be listed in pgcolumnar.objstore_allowed_endpoints,
and credentials come from the process environment, the same way read_parquet
reads a remote file. The results are identical to reading the same table from a
local path.
A data file written outside Iceberg carries no field ids. Such a file is read
through the table's schema.name-mapping.default property, which maps each
field id to the column names an id-less file may use. Each id-less column is
bound to the field id whose mapping lists its name. A file that carries field
ids ignores the mapping. An id-less file with no such property is refused,
because the specification defines no positional fallback, and the error names
the property. The mapping is read for top-level scalar columns.
Only top-level primitive columns can be read. An output column that names a
struct, list or map field is an error (SQLSTATE 0A000). This holds whatever
type the column definition list gives it. The error names the column and its
kind (#1788). The same holds for iceberg_rest_scan and the
pgcolumnar_iceberg foreign-data wrapper.
A projected column that the file does not carry reads as null. This covers a column added to the schema after the file was written. It also covers a column an id-less file has but the mapping does not bind. The file's other columns return their real values.
A column whose type was promoted reads as its current type. Iceberg allows three
promotions: int to long, float to double, and a decimal to a larger
precision at the same scale. A data file written before the promotion still
stores the old type, and its values are widened as they are read. Declare such a
column with the current schema's type: bigint, double precision or numeric.
Files from before and after the promotion read together. An equality delete on a
promoted column applies to both, whichever type its own file stores. A declared
type narrower than a file's stored type is refused. So a column declared with its
pre-promotion type errors once a file written after the promotion is live.
Row-level deletes are applied, each kind under its own Iceberg sequence rule.
A position delete drops the row ordinals it lists from the data file it names.
It applies when that data file's data sequence number is at or below the
delete's. A position delete can affect data written in the same commit, so the
two may share a sequence number. An equality delete drops every data row that
equals a delete row on all of the delete file's equality_ids columns. It
applies only when the data file's sequence number is strictly below the
delete's. An equality delete never affects data from its own commit. A null
delete value matches a null data value, and only a null. Columns in the delete
file beyond equality_ids do not take part in the match. A delete that is not
strictly newer than any data file has no effect and is skipped.
Format version 3 encodes position deletes as deletion vectors. A deletion vector is a compressed bitmap of row ordinals, stored as a blob in a Puffin file and scoped to one data file. It applies under the same sequence rule as a position delete file. When a deletion vector applies to a data file, position delete files for that file are ignored. The Iceberg specification requires the writer to fold their deletes into the vector. A snapshot may carry at most one deletion vector per data file; a second one is refused. The blob's checksum, its offsets against the Puffin footer, and its recorded cardinality are all verified, and a mismatch is refused. Deletion vectors in a table below format version 3 are refused, as is a compressed Puffin footer.
An equality delete written under a partitioned spec is applied within its
partition. Its stored partition values are compared against each data file's,
and it removes rows only from data files in the same partition. A delete for a
partition that holds no data removes nothing. Some equality-delete forms are
refused rather than ignored. A table using them errors instead of returning
rows it should have removed. A delete column is refused when its type has no
supported mapping; the supported types are int, long, string, boolean,
and date. A delete column that is no longer in the table's current schema is
refused. A delete column missing from an older data file is also an error. A
partition value the reader cannot compare exactly, such as a floating-point
value, is refused. The recorded file paths are rebased
onto the table's actual location and resolved against a path boundary. A
relocated table reads, and a path pointing outside the table is refused.
Applying equality deletes reads each affected data file's delete columns
twice. A probe pass computes the row ordinals to drop. Each applicable
equality-delete file is read once per scan into an in-memory hash table keyed
on its equality_ids columns. The probe hashes each data row once per delete
file and walks only that hash's bucket. There it compares the data row only
with delete rows that have the same hash. The cost therefore grows with the number of data
rows times the number of applicable delete files, plus the number of delete
rows. It no longer grows with data rows times delete rows. All delete rows stay
in memory until the scan ends.
A malformed manifest is refused, not read as far as it parses. A manifest entry
that records no data-file path is an error, as is a manifest whose embedded Avro
schema is not well formed. An enum value outside the Iceberg spec is refused with
XX001 and the value in the message. A data file content or an entry
status must be 0, 1 or 2. A manifest list content must be 0 or 1.
SELECT id, region, sum(amount)
FROM pgcolumnar.iceberg_scan('/data/warehouse/db/events/metadata/v3.metadata.json')
AS t(id bigint, region text, amount int)
GROUP BY id, region;
pgcolumnar.iceberg_rest_table_location(catalog_uri text, namespace text, table_name text) returns text¶
Also iceberg_rest_table_location(catalog_uri text, namespace text[], table_name text).
See Name a namespace.
Resolves a table named by an Iceberg REST catalog to the URI of its current
metadata file. The function calls the catalog over HTTP or HTTPS. It reads the
catalog configuration, loads the table, and returns the reported metadata
location. That location is an ordinary metadata path, so it is read with
iceberg_scan and the other functions above.
The catalog endpoint is subject to pgcolumnar.objstore_allowed_endpoints, the
allow-list that governs every other remote access. The module refuses a host
that resolves to a link-local range or a known instance-metadata address, whether
or not it is listed. See
the refused ranges.
The request is carried by the pgcolumnar_objstore module, so no additional TLS
library is loaded into the server process. HTTPS requires the module to be built
with OpenSSL, as for object storage.
The first argument is either a catalog URI or the name of a foreign server. A
value beginning with http:// or https:// is a URI. Any other value names a
server. A server name can never look like a URI, so the two forms never collide.
When the first argument is a URI, the token comes from the environment. It is
read from the PGCOLUMNAR_ICEBERG_REST_TOKEN variable in the server process. It
is never a function argument, so it does not appear in the statement log or in
pg_stat_activity. A catalog that needs no token is queried without one.
When the first argument names a server, the catalog URI comes from the server
and the token comes from the current role's user mapping. The role needs USAGE
on the server, as dblink requires for a named server. Without it the call is
refused with SQLSTATE 42501 before any mapping is read or any request is made,
even through a PUBLIC mapping. So REVOKE USAGE ON FOREIGN SERVER takes the
server away from a role. The token lives in
pg_user_mapping, which is not world-readable, so one role's token is not
visible to another. A role with no mapping and no token is refused. A superuser,
or a mapping that sets credentials_required 'false', uses the environment token
instead.
A user mapping may carry OAuth2 client credentials rather than a static token.
When it sets oauth_client_id and oauth_client_secret, the catalog is asked to
mint a bearer by the client-credentials grant. The request goes to
oauth_token_uri when set, otherwise to {catalog_uri}/v1/oauth/tokens, with an
optional oauth_scope. The client secret travels in the request body, never a
URL or a log line. A mapping that sets only one of the pair is refused before any
request is made, even when it also sets a static token. A mapping that sets a
token and the complete pair uses the token and mints no bearer.
The function requires the pg_read_server_files role, like the other Iceberg
functions. The server form also requires USAGE on the server.
Name a namespace¶
Every REST function that takes a namespace accepts it in two forms:
text, with levels separated by dots, such as'analytics.daily'. The function splits the text on every dot. So this form cannot name a level that itself contains a dot.text[], one element per level, such asARRAY['analytics', 'daily']. Each element is one level, exactly as given, so a level may contain a dot:ARRAY['analytics.v2']is the single levelanalytics.v2(#1865).
A bare string literal, such as 'analytics', selects the text form. The
text[] array must have one dimension, at least one element, and no NULL
element. Otherwise the call fails with SQLSTATE 22023 (22004 for a NULL
level).
A level may be empty. The Iceberg REST catalog accepts a namespace with an
empty level, so pgcolumnar sends it as given. A text[] element '' is an
empty level. In the text form, a leading, trailing or doubled dot makes one,
as in '.db', 'db.' or 'db..x', and so does ''. A stray dot often makes
one by mistake. So when the catalog answers 404 (not found), the error carries
a hint that names the empty level, such as
Level 2 of 3 of the namespace is empty (#1891). Both 404 errors carry it: "no
such namespace" (SQLSTATE 3F000) and "no such table" (SQLSTATE 42P01).
A namespace whose only level is empty, ARRAY[''] or '', is refused with
SQLSTATE 22023 (#1922). The refusal comes before any request, including a
server's OAuth token request, so it does not depend on whether the catalog can
be reached. Its request path would hold
an empty segment, such as /v1/namespaces//tables. The Apache Iceberg REST
catalog answers that path with HTTP 400, and a proxy may collapse // to /.
The REST spec has no other way to write it. The error's hint reads
Level 1 of 1 of the namespace is empty.
iceberg_rest_table_location and iceberg_rest_scan refuse an empty table
name, '', with SQLSTATE 22023 (#1931). The error reads
iceberg: the table name is empty. The refusal comes before any request,
including a server's OAuth token request. The request path would end in an
empty segment, as in /v1/namespaces/db/tables/. A proxy that strips the
trailing slash would send it to the table list instead.
A table named . or .. is refused with SQLSTATE 22023 too (#1948). So is a
namespace whose path segment would be . or .., such as ARRAY['..']. Such a
segment is a dot segment. A server or proxy that normalises the path removes
it, so /v1/namespaces/db/tables/.. would reach /v1/namespaces/db instead.
The refusal comes before any request, including a server's OAuth token
request. On a catalog whose separator is ., the text forms '.' and '..'
are refused as well. Their levels join to %2E and %2E%2E, which a
normaliser may decode to dots. That check follows the request for
/v1/config, which names the separator. A name that only holds dots, such as
a.b, ... or .hidden, is not a dot segment and is sent as given. So is
ARRAY['db','..'], whose path segment is not only dots.
A catalog cannot be asked about a level that contains its own namespace
separator, because the catalog would split that level in two. The function
then fails with SQLSTATE 22023 before it sends any request that names the
namespace. The Apache Iceberg REST
catalog uses . as its separator. On it, no form can name a level that
contains a dot, and pgcolumnar refuses ARRAY['a.b']. The check applies to the
text form too. For example, on a catalog with the default separator, a level
that holds the byte 0x1F is refused.
The error message names the level. It shows a control byte as \xNN, such as
db\x1Fx.
Every REST function first reads the catalog configuration (GET /v1/config) and
follows it:
- The
prefixgoes after/v1/in every later request. A prefix may contain/, such ascatalogs/main; each part is sent as its own path segment. - The
namespace-separatorjoins the levels of a multi-level namespace in a request. The default is the unit separator, sent as%1F. The Apache Iceberg REST catalog advertises%2E. - A key can be set in more than one place. The value in
overrideswins. Next comes a top-level key of the same name, and last the value indefaults.
-- URI form: the token, if any, comes from the server environment, not the query
SELECT pgcolumnar.iceberg_rest_table_location(
'https://catalog.example.com', 'analytics', 'events');
--> s3://warehouse/analytics/events/metadata/00042-....metadata.json
-- a namespace level that contains a dot: give the levels as text[]
SELECT pgcolumnar.iceberg_rest_table_location(
'https://catalog.example.com', ARRAY['analytics.v2'], 'events');
-- server form: the token is per-role and stays in pg_user_mapping
CREATE SERVER cat FOREIGN DATA WRAPPER pgcolumnar_iceberg_catalog
OPTIONS (catalog_uri 'https://catalog.example.com');
GRANT USAGE ON FOREIGN SERVER cat TO analyst;
CREATE USER MAPPING FOR analyst SERVER cat OPTIONS (token 's3cr3t');
SELECT count(*) FROM pgcolumnar.iceberg_scan(
pgcolumnar.iceberg_rest_table_location('cat', 'analytics', 'events'))
AS t(id bigint, region text, amount int);
The pgcolumnar_iceberg_catalog wrapper has a validator but no handler, so its
servers cannot be selected from as tables. It accepts catalog_uri and an
optional warehouse on a server. The warehouse selects a warehouse on a
multi-warehouse catalog and is sent as the ?warehouse= parameter on the
GET /v1/config request. On a user mapping it accepts token, the OAuth2 options
(oauth_client_id, oauth_client_secret, oauth_scope, oauth_token_uri), and
credentials_required. A secret on a server, where options are world-readable,
is rejected. Setting credentials_required 'false' is restricted to a superuser.
When the catalog answers with an error status, the call fails with one of these SQLSTATEs:
| Catalog reply | SQLSTATE |
|---|---|
| HTTP 401 or 403 | 28000 |
HTTP 404 of type NoSuchNamespaceException |
3F000 |
| any other HTTP 404 | 42P01 |
| any other status but 200 | 08P01 |
When the reply carries the catalog's own error message, the error detail repeats
it as The catalog said: <message>. The message is cut to 512 bytes.
pgcolumnar.iceberg_rest_scan(catalog_uri text, namespace text, table_name text) returns setof record¶
Also iceberg_rest_scan(catalog_uri text, namespace text[], table_name text).
See Name a namespace.
Reads a table named by an Iceberg REST catalog at its current snapshot. It takes
a column definition list exactly like iceberg_scan. The catalog resolves the
table to its metadata location. That location is read through the same path, so
field-id projection and every delete rule apply unchanged. The catalog and
authentication rules are those of iceberg_rest_table_location above, including
the allow-list, the link-local refusal, and the environment bearer token.
A catalog can vend storage credentials in its loadTable reply. The data,
metadata, and delete files are then read with those credentials, not the server
environment. Each file gets its own credential. For each file, the reader uses
the storage-credentials entry whose prefix is the longest match for the
file's location. A file that no prefix covers uses the flat config keys. If
the flat config vends no keys either, the read fails with SQLSTATE 28000 and
names the file. The server's own AWS_* credentials are never used for a table
whose catalog vends credentials. An entry, or a flat config, counts as a
credential only when it has both s3.access-key-id and s3.secret-access-key.
Vended credentials do not bypass the allow-list. The endpoint is still checked.
A table that vends no credentials is read with the ambient environment, as
before.
SELECT region, sum(amount)
FROM pgcolumnar.iceberg_rest_scan('https://catalog.example.com',
'analytics', 'events')
AS t(id bigint, region text, amount int)
GROUP BY region;
pgcolumnar.iceberg_rest_namespaces(catalog_uri text) returns setof text¶
Lists the namespaces of a catalog, one per row, at every level. A multi-level
namespace is returned dot-joined, for example analytics.daily. Same catalog and
authentication rules as above.
The catalog lists only the direct children of a namespace. So the function asks
for the top level, then asks for the children of each namespace it finds
(?parent=). That is one request for the top level, plus one for each
namespace. A namespace can be up to 64 levels deep. If the catalog has a
namespace 65 levels deep, the function raises an error.
The function needs every one of these requests to succeed. If the catalog
answers an error to any of them, the function raises that error and returns no
rows. This includes a catalog that does not accept ?parent=. On such a
catalog, list the tables of a namespace you know with iceberg_rest_tables.
Some catalogs also list nested namespaces at the top level. The function returns each namespace once.
The dot-joined form is ambiguous: the single level a.b and the two levels
a, b both come out as a.b. To tell them apart, use
iceberg_rest_namespace_levels. An empty level keeps its dot: the levels "",
x come out as .x (#1922).
pgcolumnar.iceberg_rest_namespace_levels(catalog_uri text) returns setof text[]¶
Lists the same namespaces as iceberg_rest_namespaces, each as a text[] of
its levels (#1865). The single level a.b comes out as {a.b}, and the two
levels a, b as {a,b}. You can pass a row back unchanged as the namespace
argument of the other REST functions, with two exceptions, described next. A
row with an empty level, such as {db,""}, passes back too (#1891). The
function also lists the children of such a namespace, such as {db,"",x},
and their children, such as {db,"",x,y} (#1939).
A level may contain the catalog's separator. No request can name such a namespace, so the function returns it but does not ask for its children. Passing that row back fails with SQLSTATE 22023.
A catalog may also hold the namespace whose only level is empty. The function
returns it as {""}. Passing that row back fails with SQLSTATE 22023 too
(#1922). The function still asks for its children, with an empty ?parent=.
The Apache Iceberg REST catalog reads an empty ?parent= as {""}, so a child
such as {"",x} is listed, and passes back. A catalog that reads an empty
?parent= as the top level gives no children of {""}.
The rows {"."} and {".."} are listed too, and passing either back fails
with SQLSTATE 22023 (#1948). The function asks for their children with
?parent=. or ?parent=... A query value is not a path segment, so a
normaliser leaves it alone.
SELECT ns, t
FROM pgcolumnar.iceberg_rest_namespace_levels('https://catalog.example.com') AS ns,
LATERAL pgcolumnar.iceberg_rest_tables('https://catalog.example.com', ns) AS t;
Caution: One level that holds the catalog's namespace separator makes this whole query fail with SQLSTATE 22023. The default separator is the byte 0x1F. Some catalogs use
., such as the Apache Iceberg REST catalog. Skip those rows first. On a catalog whose separator is.:
SELECT ns, t
FROM pgcolumnar.iceberg_rest_namespace_levels('https://catalog.example.com') AS ns,
LATERAL pgcolumnar.iceberg_rest_tables('https://catalog.example.com', ns) AS t
WHERE NOT EXISTS (SELECT FROM unnest(ns) AS level WHERE level LIKE '%.%');
On a catalog with the default separator, replace level LIKE '%.%' with
strpos(level, chr(31)) > 0.
The row {""} also makes the whole query fail with SQLSTATE 22023 (#1922). To
skip it, add AND ns <> ARRAY[''] to the WHERE clause. The rows {"."} and
{".."} do too (#1948). Add AND ns NOT IN (ARRAY['.'], ARRAY['..']) to skip
them.
pgcolumnar.iceberg_rest_tables(catalog_uri text, namespace text) returns setof text¶
Also iceberg_rest_tables(catalog_uri text, namespace text[]).
See Name a namespace.
Lists the table names in a namespace, one per row.
Both list functions follow the catalog's next-page-token, so a catalog that
splits a list into pages is read to the end.
SELECT * FROM pgcolumnar.iceberg_rest_namespaces('https://catalog.example.com');
SELECT * FROM pgcolumnar.iceberg_rest_tables('https://catalog.example.com', 'analytics');
The pgcolumnar_parquet foreign-data wrapper¶
Exposes a Parquet file, directory, or glob as a foreign table. The scan streams
one row group at a time. It holds a single row group rather than the whole file.
A LIMIT the plan satisfies early leaves the rest of the file undecoded. It
pushes work down: row groups whose min/max statistics exclude the
query's predicate are skipped, and only referenced columns are decoded. Skipping
requires a column op constant clause over an integer or floating-point column
with a constant of the same type; limitations.md lists the
conditions. A scan that skips nothing still returns correct rows.
The wrapper skips no row group on a column whose type modifier can change a
value, because the statistics hold the stored values. Examples are
numeric(18,1) over a file whose DECIMAL scale is 2, which reads a stored 99.99
as 100.0, and timestamp(3), which rounds microseconds. A timestamp(6) column,
or a numeric(p,s) column with the file's own scale and at least its precision,
still skips.
The path option can name a local file, directory, or glob, or an
object-storage URL. A remote URL is an exact object, a prefix ending in a slash,
or a pattern, expanded the same way as a local path. For a remote server the
endpoint and credentials come from the server and user-mapping options described
in Object storage.
Table options: path, and partition_columns for a Hive-style layout. The
latter names the columns whose values come from col=value directory components
rather than from the files. You declare these columns. pgColumnar does not infer
them, because an incorrect value would change the rows that a query returns, with
no message. A predicate on a partition column removes complete files before the
reader opens them. The plan shows this as
Files Pruned.
CREATE SERVER pq FOREIGN DATA WRAPPER pgcolumnar_parquet;
CREATE FOREIGN TABLE events (id int, ts timestamp, amount numeric(12,2))
SERVER pq OPTIONS (path '/data/events/');
-- events/dt=2026-01-01/region=eu/part-0.parquet
CREATE FOREIGN TABLE events_p (id int, amount numeric(12,2), dt date, region text)
SERVER pq OPTIONS (path '/data/events', partition_columns 'dt,region');
SELECT sum(amount) FROM events WHERE ts >= '2026-01-01';
-- EXPLAIN ANALYZE reports Row Groups, Row Groups Skipped, Row Groups Decoded,
-- Columns Read, Columns Total, and Files.
EXPLAIN (ANALYZE, COSTS OFF) SELECT id FROM events WHERE ts >= '2026-01-01';
The pgcolumnar_iceberg foreign-data wrapper¶
Exposes an Apache Iceberg table as a foreign table. Unlike iceberg_scan, which
is a set-returning function that receives no predicate, the foreign table gets
the query's quals and prunes data files two ways. A predicate on an
identity-partitioned column removes whole files by their partition value. An
equality predicate on a bucket[N]-partitioned column removes files whose stored
bucket differs from the constant's. A predicate on an integer or boolean column
removes whole files whose stored minimum and maximum exclude it. An unpartitioned
column can prune this way. All read from the manifest, so a file is skipped
without a read. Pruning is only an optimization. A file that is not pruned is read
normally, so a predicate the wrapper cannot decide never changes the rows
returned. Field-id projection and every delete rule are those of iceberg_scan.
A foreign table may declare a nested Iceberg column (a struct, list or map) as a
scalar. Such a table refuses every query, even one that does not read that
column (#1788).
The one table option is metadata_path, the table's current metadata.json
(a local path or an object-storage URL). The wrapper takes no server or
user-mapping options. It reads a remote table with the server process
environment's credentials, as iceberg_scan does (see
Endpoints and credentials). The wrapper requires
the pg_read_server_files role. EXPLAIN (ANALYZE) reports Files Pruned.
Partition pruning covers the identity, bucket[N], truncate[W], and temporal
transforms. Identity partitioning prunes on any predicate. bucket[N] prunes on
an equality predicate on the source column under a deterministic collation.
Iceberg buckets a string by its UTF-8 bytes, and under a nondeterministic
collation two equal strings can differ in bytes. So such an equality reads every
file. truncate[W] prunes on a predicate
on an integer source column. day() on a date source column prunes on a
predicate on that column.
The year(), month(), day(), and hour() transforms also prune on a range
or equality predicate. They apply to a timestamp or timestamp with time zone
source column, and year() and month() apply to a date column as well.
day() on a date keeps the exact path above. Each of these buckets spans a range
of source values. So a file whose bucket equals the predicate constant's bucket
is read, not skipped, and the row filter runs on it. A timestamp with time zone
value is compared as its UTC instant, matching the catalog. Metrics pruning
covers integer and boolean columns; other column types are read in full.
A column whose type modifier can change a value is never used to prune. Examples
are varchar(2), which reads a stored 'eu ' as 'eu', and timestamp(0),
which reads 00:59:59.7 as 01:00:00. The manifest holds the stored value, so
it does not describe the value the scan returns.
CREATE SERVER ice FOREIGN DATA WRAPPER pgcolumnar_iceberg;
CREATE FOREIGN TABLE events (id bigint, region text, amount int)
SERVER ice OPTIONS (metadata_path '/data/warehouse/db/events/metadata/v3.metadata.json');
-- reads only the region=eu data file; EXPLAIN ANALYZE shows "Files Pruned: 1"
SELECT sum(amount) FROM events WHERE region = 'eu';
Object storage¶
The Parquet read and export functions, export_arrow and the Iceberg reader
accept an object-storage URL wherever they accept a local path. So do the
pgcolumnar_parquet and pgcolumnar_iceberg foreign-data wrappers. The third
wrapper, pgcolumnar_iceberg_catalog, takes no path. import_arrow,
file_split_offsets, parallel_copy, read_avro_manifest and
read_manifest_list read local files only. Each of them refuses a URL in any of
the schemes below with SQLSTATE 0A000 before it reads anything, for example
columnar: import_arrow does not read "s3://bucket/m.arrow". These URL schemes
are recognized:
| Scheme | Meaning |
|---|---|
s3://bucket/key |
An S3 or S3-compatible object. The request is signed with AWS Signature Version 4. |
s3a://bucket/key, s3n://bucket/key |
The Hadoop names for S3, matched in any case. The readers read them as s3://bucket/key: the Parquet functions, the pgcolumnar_parquet wrapper and the Iceberg reader. The exports refuse them with SQLSTATE 0A000, for example columnar: export_parquet does not write "s3a://bucket/t.parquet", with a hint that names the s3:// URL. The local-only functions above refuse them too. |
gs://bucket/key |
A Google Cloud Storage object, read and written through the interoperable XML API. It signs with the same Signature Version 4 as s3://, so a GCS HMAC key is given as AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY. The endpoint defaults to https://storage.googleapis.com and the region to auto; set AWS_ENDPOINT_URL to override. |
http://host[:port]/path |
A plain-HTTP object. The request carries no credential, but the data crosses the network in clear, so use it on a trusted network only. |
https://host[:port]/path |
An HTTPS object. Available when the object-store module was built with OpenSSL. The server certificate is verified. |
az://container/key |
Reserved for Azure Blob Storage, which no module reads yet. pgcolumnar treats the URL as remote, not as a local path, and refuses it with SQLSTATE 0A000: reading "az://..." is not supported, or, without the object-store module, reading "az://..." requires the object-store module. |
A reader rewrites an s3a:// or s3n:// URL to s3:// before it lists, signs or
opens anything. So the request, the endpoint and the credentials are those of the
s3:// URL. Error messages name the s3:// URL too, because that is the URL that was
requested. Only the start of the URL counts: xs3a:// or s3ax:// is a local path.
Requests to an http:// or https:// URL are never signed and carry no
credential, for reads and for exports alike. An export sends one PUT, or, for a
large object, the multipart POST and PUT requests and the DELETE that aborts a
failed upload. None of them has an Authorization header. Use these schemes for a
server that accepts unsigned requests, and s3:// or gs:// for a store that needs
credentials. If such a server refuses a request with HTTP 403, the error hint says
the request was unsigned.
Credentials go over plain HTTP only when an s3:// or gs:// URL has an http://
AWS_ENDPOINT_URL. The request is then signed, and the signature, the access key ID
and any AWS_SESSION_TOKEN cross the network in clear. The secret access key is
never sent.
S3 requests use path-style addressing (endpoint/bucket/key) by default. Set
pgcolumnar.objstore_s3_addressing to virtual for virtual-host addressing
(bucket.endpoint/key), which is what AWS now prefers; the endpoint allow-list
still authorizes the endpoint, not the per-bucket hostname.
Object-storage support lives in a separate module, pgcolumnar_objstore, which
loads on the first use of a remote URL and never before. A build or an install
without it reads and writes local files as before, and a remote URL reports that
the module is required.
A remote path is read as an exact key, a prefix, or a pattern. An s3:// URL
that names an object reads that one object, with no listing. A key that holds
*, ? or [ is the exception described below. The read starts
with one HEAD, then fetches the footer and the column data with ranged GETs. When
the HEAD returns a strong ETag of at most 79 characters, every GET sends that
ETag in an If-Match header. The 79 includes the ETag's two quote marks. If
another client then overwrites the object during the read, the server answers 412. The query then fails with changed while it was
being read, instead of returning rows from two versions. Run the statement again
to read the new version. When the HEAD also returns x-amz-version-id, as a
versioned S3 bucket does, every GET asks for that version with versionId=. An
overwrite during the read then leaves the read on the version it started on,
and the query succeeds. A GET that names a version needs the
s3:GetObjectVersion permission. If the store answers such a GET with 403, the
reader asks again without the version, for the rest of that read, and keeps
If-Match. The reader also compares the total length in each 206
Content-Range with the length from the HEAD. When they differ, the query fails
with changed while it was being read, whatever the server sends as ETag. The
query fails the same way when a GET for bytes inside the HEAD's length gets 416:
the object has shrunk. Without a strong ETag of at most 79 characters and
without a version id, the read is not pinned. A server that sends no ETag, a
weak one, or a longer one pins the read only by version id (see
limitations). Set
pgcolumnar.objstore_pin_reads to off for a server whose ETag changes from
one request to the next: the reader then sends no If-Match and no
versionId. Two reads of one object in a statement, such as a self-join, read
one version (#1894). The second read asks for the version the first one
opened. When the store names no version, the second read fails with changed
while it was being read if the ETag or the length changed in between. With
no strong ETag, only a change of length is caught. The pin lasts one
top-level statement (see
limitations). An
s3://bucket/prefix/ URL that ends in a slash lists every object under the
prefix, at any depth, like a local directory. An s3://bucket/prefix/*.parquet
URL lists the literal prefix and matches the pattern segment by segment, like a
local glob. Listing is a paged ListObjectsV2 call. Below a prefix, _SUCCESS,
_temporary, and other names that begin with _ or . are skipped, as they are
below a local directory. The prefix you name may itself begin with _ or .,
such as s3://bucket/_landing/. A pattern matches names as a local glob does.
It matches a name that begins with _. It matches a name that begins with .
only when the pattern spells the dot, such as .*.parquet. The scheme is
compared without case, so S3:// reads as s3:// does. Hive
partition_columns work over a remote prefix. The object-store module issues the
listing, as it issues every remote read.
Only the key of an s3:// URL, the part after the bucket, can be a pattern. The
scheme and the bucket never are. An http:// or https:// URL is always one
object, so a ? in it starts a query string, which is sent to the server. A key
may itself contain *, ? or [, because S3 allows them. When an s3:// URL
holds one of these characters, the reader lists the prefix and first looks for
an object with exactly that key. If one exists, the reader reads that object
alone, whatever its suffix. Only when none exists is the key matched as a
pattern. A backslash before a character makes the pattern match that character
literally, so s3://bucket/a/q\?.parquet matches only a/q?.parquet. A read
from a host written as an IPv6 literal in brackets, such as
http://[::1]:9000/, is refused with SQLSTATE 0A000. When an http(s):// URL
has a query string, the reader and the writer add their own parameters to it
with &. Examples are versionId= and uploadId=.
On write, parallel_export_parquet treats an s3:// URL as a prefix, and
refuses every other remote URL before any worker starts. It writes one
part-NNNN.parquet object under the prefix per worker, and an empty _SUCCESS
marker beside them on completion. Trailing slashes on the prefix make no
difference: s3://bucket/out/, s3://bucket/out// and s3://bucket/out all
write out/part-0000.parquet and out/_SUCCESS. The scheme may be written in
any case, so S3://bucket/out writes the same keys. Before it writes the marker,
the export deletes any part-NNNN.parquet directly under out/ that a larger
earlier run left behind. It looks only directly under out/, so objects under
outer/, out2/ or a deeper prefix such as out/sub/ are never touched. The prefix must name something under the bucket:
s3://bucket/ and s3://bucket are refused before any worker starts, with
SQLSTATE 22023. The workers use the calling session's values of
pgcolumnar.objstore_allowed_endpoints, pgcolumnar.objstore_s3_addressing and
pgcolumnar.objstore_part_size, including any set with SET. The endpoint is
checked against the session's allow-list before any worker starts, so a refused
endpoint fails before anything is uploaded. Every other write function writes a
single object at the exact key.
Remote paths carry the same privilege as local ones. A read needs
pg_read_server_files and a write needs pg_write_server_files, over and above
any table privilege.
Endpoints and credentials¶
The endpoint and the credentials come from the server process environment, from the catalog, or from both.
The function API (read_parquet, export_parquet, export_arrow,
import_parquet, parquet_schema) has no server object. Its endpoint and
credentials come from the environment of the server process:
| Variable | Meaning |
|---|---|
AWS_ENDPOINT_URL |
The object-storage endpoint, http://... or https://.... Required for an s3:// URL; optional for gs://, which defaults to https://storage.googleapis.com. |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
The access key pair. |
AWS_SESSION_TOKEN |
A session token, when the credentials are temporary. Optional. |
AWS_REGION or AWS_DEFAULT_REGION |
The signing region. |
Two calls take a server instead: export_parquet(rel, path, server), and
tier_partition, which writes through it. They resolve the endpoint and the
credentials as the foreign-data wrapper does, as described next.
For the pgcolumnar_parquet foreign-data wrapper the endpoint and credentials
come from the catalog, which keeps them out of a world-readable place. The
non-secret settings go on the server and the secret ones go on a user mapping:
CREATE SERVER s3 FOREIGN DATA WRAPPER pgcolumnar_parquet
OPTIONS (endpoint 'https://s3.example.com', region 'us-east-1');
CREATE USER MAPPING FOR analyst SERVER s3
OPTIONS (access_key_id '...', secret_access_key '...');
CREATE FOREIGN TABLE events (id int, ts timestamp, amount numeric(12,2))
SERVER s3 OPTIONS (path 's3://reports/events.parquet');
The wrapper accepts endpoint and region only on the server, and
access_key_id, secret_access_key, session_token, and credentials_required
only on a user mapping. Any other placement is an error at CREATE or ALTER
time. pg_user_mapping is not world-readable, so a secret placed there is not
exposed the way a server option is.
A server with no endpoint or region option takes that value from
AWS_ENDPOINT_URL or AWS_REGION (or AWS_DEFAULT_REGION) in the environment.
This applies to every caller, because an endpoint and a region are not secrets.
A read resolves the scanning user's mapping first, then a PUBLIC mapping. When a
mapping supplies no credentials, the server process environment is used only in
two cases. The caller is a superuser. Or a superuser has marked the mapping
credentials_required 'false'. Ambient credentials are a privilege, not a
default. An ordinary role with no mapping is refused rather than given the server
process identity.
The pgcolumnar_iceberg wrapper works differently. It takes no server or
user-mapping options, so it has no per-role credentials and no
credentials_required. It reads with the server process environment, as
iceberg_scan does, for every role that may read the foreign table. Its gate is
the pg_read_server_files role.
The endpoint allow-list¶
pgcolumnar.objstore_allowed_endpoints lists the endpoints the module may
connect to. It is empty by default, which refuses every remote endpoint. A role
that can read server files therefore cannot use the extension to reach an
arbitrary host. A superuser sets it to the endpoints the deployment uses:
Each entry is a host or a host:port. A request whose endpoint matches no entry
is refused before any connection is made. The link-local ranges and the known
cloud instance-metadata addresses are refused unconditionally, whether or not
they appear in the list. These include 169.254.169.254 and Alibaba Cloud's
100.100.100.200. For every refused range, see
Run object storage safely. The
setting is superuser-only, so a role cannot widen its own reach.
Exporting to object storage¶
export_parquet and export_arrow write to an s3:// URL as well as a local
path. A small object goes in one request. A large one is written as a multipart
upload. It becomes visible at its final name only when the upload completes, so a
reader never sees a partial object. An export that fails before its last request
removes what it wrote, including an incomplete multipart upload. The last request
is different, as the next paragraphs explain.
A request whose body is over 1 MiB, a single request or a part, sends its headers
first with Expect: 100-continue. It waits up to one second for the store to
answer them before it sends the body. A store can refuse the request from its
headers alone, with a 403 for example. The export then reports that status and its
hint, over https:// as well as http://. A store that ignores Expect costs
that second once per object written. After a wait runs out, the later parts of
that object go without Expect. A store that answers 417 gets the request again without
Expect. A 2xx answer to the headers alone still gets the body, and stands as
the answer once the body is sent.
This cleanup holds when you cancel or terminate the export while it waits for the
store to start the upload. The export then has no id for that upload. It lists
the open uploads for its own key and aborts each one, within 10 seconds. Uploads
at other keys stay open. An upload that another writer started at the same key is
aborted too. This works for s3:// and gs:// URLs.
A multipart upload holds at most 10000 parts of pgcolumnar.objstore_part_size
bytes each, 8 MiB by default. An export that needs more parts fails with SQLSTATE
54000 before it sends part 10001, and aborts the upload. Raise the part size for
an object over about 78 GiB. AWS S3, Google Cloud Storage and Cloudflare R2
refuse parts under 5 MiB, except the last. To those stores, an export with a
smaller part size fails with SQLSTATE 22023 when it starts a multipart upload,
before it sends a part. A small object that fits in one part still goes in one
request. Other stores are not checked.
The export reads each reply from the store by the HTTP/1.1 framing rules. A reply
with neither a Content-Length nor chunked framing ends when the store closes
the connection; a 204 reply has no body. Some stores send such a reply to a
write and keep the connection open. The export then waits 60 seconds and fails
with "timeout reading". A chunked or close-delimited write reply over 1 MiB fails
the export with "response exceeded". This holds even when the store has already
written the object.
The last request commits the object. It is the single request of a small object, or the request that completes a multipart upload. Once the store has that whole request, a cancel or terminate waits for its answer. The wait ends when the answer arrives, or when the store sends nothing for 60 seconds. A store that keeps sending slowly can make it wait longer. If the store accepted the request, the export issues a warning that it wrote the object. The statement can still end with the cancel error. The new object is then at the key and has replaced any earlier object there. A store cannot take a committed object back.
If the connection closes during that wait, the export does not send the request again. It fails with an error that says the store may have written the object. Check the key to see whether it holds the new object.
A backend killed with SIGKILL, or a lost connection to the store, can still
leave an incomplete multipart upload. To reclaim one, set a bucket lifecycle rule
that expires incomplete multipart uploads after a few days.
Export to object storage is not transactional. An export whose transaction later rolls back has already written the object. This is true of a local export as well, and is more visible when the artifact is remote and shared.
Visibility map inspection¶
These report the state of the columnar visibility-map fork that serves index-only scans. They are for diagnostics.
pgcolumnar.vm_is_visible(rel regclass, blk int)¶
Tells you if the block (chunk group) has the all-visible mark.
pgcolumnar.vm_selftest(rel regclass, blk int)¶
Runs a set and clear self-test against the visibility map for one block.