| name | iceberg-explorer | ||||||
|---|---|---|---|---|---|---|---|
| description | Explore and query any Apache Iceberg table exposed through an Iceberg REST catalog — Polaris/Snowflake Open Catalog, Lakekeeper, Databricks Unity Catalog, Nessie, AWS Glue, S3 Tables, Gravitino, or any spec-compliant implementation. This skill gives you an agentic, cost-aware interface to explore that data (read-only). Use for ANY question about Iceberg tables: schemas, namespaces, row counts, freshness, partitioning, snapshots, or running queries. Trigger on: "what tables do we have", "explore my lakehouse/catalog", "query the iceberg catalog", "check data freshness", "describe this iceberg table", "how many rows", "show me our iceberg tables". | ||||||
| metadata |
|
||||||
| user-invocable | true | ||||||
| argument-hint | <question about your Iceberg tables, schemas, or data> | ||||||
| allowed-tools | bash(duckdb, curl, python3, pip, aws) |
Explore and query Apache Iceberg tables through any Iceberg REST catalog, cost-efficiently. The skill is catalog-agnostic: it speaks the standard Iceberg REST protocol and supports three authentication modes, so it works with managed services (Snowflake Polaris/Open Catalog, Databricks Unity, Fivetran MDLS), open-source catalogs (Lakekeeper, Nessie, Gravitino), and AWS-native catalogs (Glue, S3 Tables).
Fill in .iceberg_config.json in this skill's directory before using the skill.
The required fields depend on your auth_type (see the table below). Every field
you need for your chosen auth_type must be a non-empty string.
{
"catalog_uri": "",
"warehouse": "",
"catalog_alias": "iceberg_catalog",
"auth_type": "oauth2",
"oauth2_client_id": "",
"oauth2_client_secret": "",
"oauth2_scope": "",
"oauth2_server_uri": "",
"token": "",
"aws_region": "",
"sigv4_signing_name": "",
"endpoint_type": "",
"storage_region": ""
}| Config key | Required for | Meaning |
|---|---|---|
catalog_uri |
all (except pure sigv4 Glue/S3 Tables, which use endpoint_type) |
REST catalog base URL, e.g. https://my-org.us-west-2.aws.polaris.fivetran.com/api/catalog, http://localhost:8181/catalog (Lakekeeper), https://<workspace>.cloud.databricks.com/api/2.1/unity-catalog/iceberg (Unity) |
warehouse |
most | Warehouse / catalog identifier used as the REST prefix and in DuckDB ATTACH. For Glue this is the AWS account id; for S3 Tables it's the table-bucket ARN |
catalog_alias |
all | DuckDB ATTACH ... AS <alias> name. Defaults to iceberg_catalog. This is the catalog prefix in all SQL |
auth_type |
all | One of oauth2, bearer, sigv4 |
oauth2_client_id / oauth2_client_secret |
oauth2 |
Client-credentials pair |
oauth2_scope |
oauth2 (optional) |
Scope string. Polaris/Open Catalog use PRINCIPAL_ROLE:ALL; many catalogs use catalog or leave it blank |
oauth2_server_uri |
oauth2 |
Token endpoint. For Polaris-style catalogs this is {catalog_uri}/v1/oauth/tokens. For catalogs that delegate to an external IdP (e.g. Unity, some Lakekeeper setups), use that IdP's token URL |
token |
bearer |
Pre-issued bearer token / PAT |
aws_region |
sigv4 |
AWS region of the Glue/S3 Tables endpoint |
sigv4_signing_name |
sigv4 (optional) |
glue or s3tables. Usually inferred from endpoint_type |
endpoint_type |
sigv4 |
DuckDB ENDPOINT_TYPE: glue or s3_tables |
storage_region |
optional | Region for vended object-storage credentials (DEFAULT_REGION in DuckDB). For sigv4 defaults to aws_region |
Auth-type cheat sheet
oauth2— Polaris / Snowflake Open Catalog, Fivetran MDLS, Lakekeeper (OAuth mode), Unity (when fronted by client-credentials), Nessie with OAuth.bearer— any catalog where you hold a static bearer token / PAT (some Lakekeeper, Nessie, Unity PAT setups).sigv4— AWS Glue Iceberg REST and S3 Tables, signed with AWS SigV4 via the default credential chain.
Never overwrite .iceberg_config.json — it holds the user's credentials.
Requires the DuckDB CLI (>=1.3.0 recommended). Test with duckdb --version.
Install from https://duckdb.org/docs/installation/.
Version floor by backend: GCS-backed storage needs >=1.2; AWS Glue / S3 Tables
ENDPOINT_TYPEsupport stabilized around 1.3. Use 1.3+ to be safe across every backend.
The sigv4 REST tier and credential checks use aws. Test with aws sts get-caller-identity.
pip install "pyiceberg[pyarrow]"Step 1 — Verify DuckDB.
duckdb --versionIf it fails, tell the user DuckDB is not installed and link https://duckdb.org/docs/installation/.
Step 2 — Read .iceberg_config.json from the skill's base directory and validate.
The skill base directory is injected at load time (top of this prompt). Read
<skill_base_dir>/.iceberg_config.json. Validate that every field required for
the configured auth_type is a non-empty string. If any required field is
empty, stop and ask the user to fill it in:
"Your
.iceberg_config.jsonis missing values for:<list of blank required fields>(required forauth_type: <type>). Please fill them in and let me know when it's ready."
Do not proceed until validated. Source every value from the config — never hardcode.
Step 3 — Verify catalog connectivity.
The check differs by auth_type:
-
oauth2/bearer— use the Standard REST Call Pattern to hit the spec-standard config endpoint, which validates auth and returns the canonicalprefix/defaults:GET {catalog_uri}/v1/config?warehouse={warehouse}Then list namespaces:
GET {catalog_uri}/v1/{warehouse}/namespaces -
sigv4— REST calls require SigV4 signing, which curl can't do cleanly. Verify with the AWS CLI instead:aws sts get-caller-identity # Glue: aws glue get-catalog-import-status 2>/dev/null || aws glue get-databases --max-results 1 # S3 Tables: aws s3tables list-table-buckets --region {aws_region}
If connectivity fails, re-check catalog_uri, warehouse, and the auth fields for the configured auth_type.
Token lifetime (
oauth2): tokens typically expire in ~1 hour. On401, re-acquire (the cached-token pattern below handles this) and retry.
The metadata-first principle (below) relies on cheap REST calls. How you make
them depends on auth_type.
Tokens are valid ~1 hour. Cache to /tmp and reuse. Include this at the top of
any bash script that makes REST calls, then use $ICEBERG_TOKEN freely.
ICEBERG_TOKEN=$(python3 - << 'PYEOF'
import os, time, json, urllib.request, urllib.parse, hashlib
key = hashlib.md5("{warehouse}|{catalog_uri}".encode()).hexdigest()[:12]
cache = f"/tmp/iceberg_token_{key}"
if os.path.exists(cache) and time.time() - os.path.getmtime(cache) < 3000:
print(open(cache).read().strip())
else:
params = {"grant_type": "client_credentials",
"client_id": "{oauth2_client_id}",
"client_secret": "{oauth2_client_secret}"}
scope = "{oauth2_scope}"
if scope:
params["scope"] = scope
data = urllib.parse.urlencode(params).encode()
req = urllib.request.Request("{oauth2_server_uri}", data=data)
token = json.loads(urllib.request.urlopen(req).read())["access_token"]
open(cache, "w").write(token)
print(token)
PYEOF
)
curl -s -H "Authorization: Bearer $ICEBERG_TOKEN" "<endpoint URL>" | python3 -m json.toolCache file is keyed by a hash of
warehouse|catalog_uriso multiple catalogs on one machine don't collide. TTL 3000s (50 min), conservative vs the 1-hour expiry.
ICEBERG_TOKEN="{token}"
curl -s -H "Authorization: Bearer $ICEBERG_TOKEN" "<endpoint URL>" | python3 -m json.toolSigV4 request signing in shell is error-prone. For metadata, prefer:
- pyiceberg loaded with
rest-sigv4auth (see Catalog Discovery), or - the native AWS CLI (
aws glue get-table …,aws s3tables get-table …), or - DuckDB's
iceberg_metadata()on the attached catalog (Tier 2, but metadata-only operations read little).
Every DuckDB invocation is a fresh in-memory database. Use a persistent secret
(stored to ~/.duckdb/stored_secrets/) so DuckDB auto-loads credentials in every
later session and you can drop the CREATE SECRET from routine scripts.
The secret + ATTACH form depends on auth_type.
oauth2:
cat > /tmp/iceberg_setup.sql << 'TEMPLATE'
INSTALL httpfs FROM core; LOAD httpfs;
INSTALL iceberg FROM core; LOAD iceberg;
CREATE OR REPLACE PERSISTENT SECRET iceberg_secret (
TYPE ICEBERG,
CLIENT_ID '{oauth2_client_id}',
CLIENT_SECRET '{oauth2_client_secret}',
OAUTH2_SCOPE '{oauth2_scope}',
OAUTH2_SERVER_URI '{oauth2_server_uri}'
);
TEMPLATE
duckdb < /tmp/iceberg_setup.sqlIf
oauth2_scopeis blank, omit theOAUTH2_SCOPEline entirely.
bearer:
cat > /tmp/iceberg_setup.sql << 'TEMPLATE'
INSTALL httpfs FROM core; LOAD httpfs;
INSTALL iceberg FROM core; LOAD iceberg;
CREATE OR REPLACE PERSISTENT SECRET iceberg_secret (
TYPE ICEBERG,
TOKEN '{token}'
);
TEMPLATE
duckdb < /tmp/iceberg_setup.sqlsigv4: the storage/credential secret is an AWS secret using the default
credential chain (no token to persist):
cat > /tmp/iceberg_setup.sql << 'TEMPLATE'
INSTALL httpfs FROM core; LOAD httpfs;
INSTALL iceberg FROM core; LOAD iceberg;
CREATE OR REPLACE PERSISTENT SECRET aws_secret (
TYPE s3,
PROVIDER credential_chain,
REGION '{aws_region}'
);
TEMPLATE
duckdb < /tmp/iceberg_setup.sqloauth2 / bearer:
cat > /tmp/iceberg_query.sql << 'TEMPLATE'
INSTALL httpfs FROM core; LOAD httpfs;
INSTALL iceberg FROM core; LOAD iceberg;
ATTACH '{warehouse}' AS {catalog_alias} (
TYPE ICEBERG,
ENDPOINT '{catalog_uri}',
SECRET iceberg_secret,
DEFAULT_REGION '{storage_region}'
);
<your SQL here>
TEMPLATE
duckdb < /tmp/iceberg_query.sqlsigv4 — Glue:
ATTACH '{warehouse}' AS {catalog_alias} (TYPE ICEBERG, ENDPOINT_TYPE 'glue');sigv4 — S3 Tables ({warehouse} is the table-bucket ARN):
ATTACH '{warehouse}' AS {catalog_alias} (TYPE ICEBERG, ENDPOINT_TYPE 's3_tables');If the persistent secret isn't set up yet, prepend the matching
CREATE OR REPLACE PERSISTENT SECRETblock before theATTACH— it creates and persists in the same invocation.
The catalog is always attached as {catalog_alias}. Use it as the prefix in all SQL:
DESCRIBE {catalog_alias}.<namespace>.<table>;
SELECT COUNT(*) FROM {catalog_alias}.<namespace>.<table>;Do not use {warehouse} as the SQL catalog prefix — that value is only used
in ATTACH and in REST URL paths.
Catalog metadata first. Always. No exceptions.
Every DuckDB data query reads Parquet from object storage via vended/assumed
credentials — that costs money and can crash the machine on large datasets. The
Iceberg REST API (or iceberg_metadata() for sigv4) returns catalog metadata
with zero data egress. Most questions are answerable from metadata alone. Only
fall through to a data scan when you genuinely need row-level data the catalog
can't provide.
Two different identifiers. Using the wrong one causes 400/404 errors.
| Context | Value | Source |
|---|---|---|
| REST API calls (URL path prefix) | {warehouse} |
warehouse in config (or the prefix returned by /v1/config) |
| DuckDB SQL queries | {catalog_alias} |
The ATTACH ... AS {catalog_alias} alias — never changes |
Nested namespaces: the Iceberg REST spec joins multi-level namespaces with the unit-separator char (
%1F, URL-encoded) in paths, e.g./v1/{warehouse}/namespaces/a%1Fb/tables. In DuckDB SQL they appear as{catalog_alias}.a.b.<table>— wrap dotted parts in double quotes if needed.
Never assume catalog structure. Discover live state before answering anything about tables or writing any query.
# Acquire/reuse token per the Standard REST Call Pattern, then:
# 1. List namespaces
curl -s -H "Authorization: Bearer $ICEBERG_TOKEN" \
"{catalog_uri}/v1/{warehouse}/namespaces" | python3 -m json.tool
# 2. List tables in a namespace
curl -s -H "Authorization: Bearer $ICEBERG_TOKEN" \
"{catalog_uri}/v1/{warehouse}/namespaces/<namespace>/tables" | python3 -m json.tool
# 3. Full table metadata (schema, partitions, snapshots) — before writing any query
curl -s -H "Authorization: Bearer $ICEBERG_TOKEN" \
"{catalog_uri}/v1/{warehouse}/namespaces/<namespace>/tables/<table>" | python3 -m json.toolfrom pyiceberg.catalog import load_catalog
cat = load_catalog("rest", **{
"type": "rest",
"uri": "{catalog_uri}", # Glue/S3 Tables REST URI
"warehouse": "{warehouse}",
"rest.sigv4-enabled": "true",
"rest.signing-name": "{sigv4_signing_name}", # "glue" or "s3tables"
"rest.signing-region": "{aws_region}",
})
print(cat.list_namespaces())
for ns in cat.list_namespaces():
print(ns, cat.list_tables(ns))
# Full metadata:
t = cat.load_table("<namespace>.<table>")
print(t.schema(), t.spec(), t.metadata.snapshots[-1].summary)Never guess column names, partition columns, or row counts — fetch them first.
loadTable returns the full Iceberg metadata JSON:
- Schema — every column name, type, nullability, field id
- Partition spec — partition columns and transforms
- Sort order — clustering keys if any
- Snapshots — full commit history: timestamp, operation, added/deleted file & record counts, totals
- Current snapshot summary —
total-records,total-files-size,total-data-files,total-delete-files - Table properties and storage location
Row counts, file sizes, and record counts come straight from the catalog —
never run COUNT(*) if the snapshot summary already has it.
Iceberg writers record per-file lower_bounds/upper_bounds in the manifest Avro
files (referenced by the snapshot's manifest-list), not in the loadTable JSON.
DuckDB reads these automatically when planning, skipping Parquet files whose
bounds fall outside your filter — column bound pruning, visible in
EXPLAIN ANALYZE as Dynamic Filter (<col>) in the TABLE_SCAN node. This works
even on unpartitioned tables.
Practical implication: filtering on columns with a wide value spread
(timestamps, primary keys, audit columns like _fivetran_synced / updated_at
if your writer adds them) skips many files. See Column Bounds Inspection to inspect actual bound values.
Work through these in order. Stop at the first tier that can answer.
Use for: namespaces, table lists, schemas, types, partition specs, sort orders, snapshot history, table properties, file counts, table-level stats, catalog structure.
oauth2/bearer: curl the REST endpoints below. sigv4: pyiceberg or aws CLI.
Standard Iceberg REST endpoints (oauth2/bearer):
GET {catalog_uri}/v1/config?warehouse={warehouse} # catalog defaults + prefix
GET {catalog_uri}/v1/{warehouse}/namespaces # list namespaces
GET {catalog_uri}/v1/{warehouse}/namespaces/<namespace> # namespace properties
GET {catalog_uri}/v1/{warehouse}/namespaces/<namespace>/tables # list tables
GET {catalog_uri}/v1/{warehouse}/namespaces/<namespace>/tables/<table> # full table metadata
These five are part of the Iceberg REST spec and portable across catalogs. Vendor-specific endpoints (roles, principals, grants) are not — see the Vendor Appendix.
Only when the question needs actual row data the catalog can't provide.
Step 1: Run plain EXPLAIN first — free, zero egress.
Inspect the logical plan: are your filters in the TABLE_SCAN node? Only needed
columns projected? Right structure? If not, rewrite and re-EXPLAIN until clean.
Step 2: Run EXPLAIN ANALYZE to get real execution stats.
EXPLAIN ANALYZEactually executes the query. The stats are real, not estimates — the storage cost has already been incurred. Running the real query afterward costs the same again.
Step 3: Present the plan report and wait for explicit confirmation.
📊 Query Plan Report
─────────────────────────────────────
⚠️ EXPLAIN ANALYZE executes the query — these are real numbers. The storage
cost below has already been incurred. Running the real query costs the same again.
Query: <the SQL you intend to run>
HTTPFS Stats:
Data read: <in: X KiB/MiB/GiB>
HTTP GETs: <#GET count> ← number of object-storage file reads
Execution:
Total time: <Xs>
Files scanned: <Total Files Read: N>
Rows scanned: <rows at TABLE_SCAN node>
Rows returned: <rows at top node>
Plan quality:
Filters pushed down: <filters in TABLE_SCAN, or "none">
Projections only: <columns in TABLE_SCAN Projections>
Partition pruning: <yes/no — files scanned vs total files>
Column bound pruning: <yes/no — "Dynamic Filter (<col>)" in TABLE_SCAN>
Small files warning: <flag if data-files / records ratio > 1 file per 100 rows>
Shall I run this query? Running it incurs the cost above a second time. (yes / no / suggest alternative)
Execute the real query only after explicit confirmation. On "no", revise →
re-EXPLAIN ANALYZE → present a new report.
- HTTPFS HTTP Stats —
in: X bytes= actual storage transferred;#GET= file reads. Zero bytes = metadata only. - TABLE_SCAN node —
Total Files Read, appliedFilters,Projections. Filters present = pushdown worked. Dynamic Filter (<col>)— column bound pruning active (distinct from partition pruning, works unpartitioned).- Row counts per node and Total Time (wall clock incl. storage latency).
| Signal | Good | Bad |
|---|---|---|
| Files Read | 1–3 | >10 |
| Filters in TABLE_SCAN | Present | Absent (full scan) |
| Dynamic Filter | Present | Absent |
| Projections | Only needed columns | All (SELECT *) |
| Data in (HTTPFS) | KiB | MiB/GiB |
| #GET | 1–10 | >50 (even if data is small — small-files problem) |
| files / rows ratio | <1 per 100 rows | >1 per 100 rows |
High #GET with small data = file fragmentation. Recommend compaction/OPTIMIZE, not a query rewrite.
- Fully qualified names:
{catalog_alias}.<namespace>.<table> - Confirm schema from Tier 1 first — know columns and partitions before writing SQL
- Always
LIMITany row-returningSELECT— hard cap 100 - Prefer aggregations (
COUNT,GROUP BY,MIN,MAX,AVG) over fetching rows - Filter on partition or bound columns — check the partition spec from Tier 1; if unpartitioned, filter on a high-cardinality column (timestamp, primary key, or an audit column like
_fivetran_synced/updated_atif present) to trigger column bound pruning - Filter soft-deletes when present — e.g.
WHERE _fivetran_deleted IS NOT TRUE(Fivetran) or your writer's equivalent. Only apply if the column exists in the schema you fetched - Never scan large tables without a partition or time filter — check row counts and partitions from Tier 1 first
- Select only needed columns — never
SELECT *unless explicitly asked
-- Aggregation (safe)
SELECT <group_col>, COUNT(*) AS n
FROM {catalog_alias}.<namespace>.<table>
GROUP BY <group_col> ORDER BY n DESC;
-- Filtered sample (safe — only needed columns)
SELECT <col1>, <col2>
FROM {catalog_alias}.<namespace>.<table>
WHERE <partition_col> = '<value>'
LIMIT 20;- Use
DESCRIBE {catalog_alias}.<ns>.<table>— notinformation_schema.columns(returns UNKNOWN types for Iceberg) - Use
information_schema.tables— notSHOW TABLES/SHOW SCHEMAS duckdb_tables()doesn't returnestimated_sizefor Iceberg tables- Always prefix
{catalog_alias}.— unqualified names fail iceberg_metadata({catalog_alias}.<ns>.<table>)returns per-file info (manifest/file paths, record counts) on the attached catalog. It does not expose per-column bounds — use pyiceberg for thoseparquet_metadata('<storage://path>')does not work — it bypasses the catalog secret and hits storage with no/anonymous creds (403 / access denied). Useiceberg_metadata()or pyiceberg
If EXPLAIN ANALYZE shows an unsafe plan even after rewriting (no filters
available, millions of rows, GiB-range read), do not present it. Instead:
- Explain why it's unsafe (files scanned, data volume)
- Offer a safe alternative (aggregation, filtered subset, catalog metadata)
- Ask whether to proceed with the alternative
Unsafe patterns: full scan of a large table with no partition filter; log tables with no time filter; multi-table joins with no selective filter on either side.
Question received
│
▼
Read .iceberg_config.json → validate fields for auth_type → extract values
│
▼
Can catalog metadata answer this?
(schema, columns, partitions, snapshots, row counts, file sizes, properties)
│
YES ──► Tier 1 (REST curl | pyiceberg | aws CLI) → Answer (zero egress)
NO
│
▼
Write best DuckDB query (partition filters, projections, aggregations, LIMIT)
│
▼
Run EXPLAIN ← free, zero egress
│
Plan clean? ──NO──► rewrite → EXPLAIN again
│YES
▼
Run EXPLAIN ANALYZE ← executes, real cost incurred
│
▼
Present Query Plan Report → user confirms?
│
YES ──► run real query → present results
NO ──► revise → EXPLAIN → EXPLAIN ANALYZE → repeat
│
Fundamentally unsafe even after rewrite
└──► explain why → offer safe alternative → ask to proceed
- Lead with the answer, not the tool call or SQL.
- State which tier: "From catalog metadata — no storage read." / "Ran aggregation on [table] ([N] rows scanned)."
- Show SQL / curl after the answer, not before.
- Tabular results → markdown table (cap 20 rows). Bytes → human-readable. Schema → clean column/type table.
Diagnostic tool only. Reads manifest Avro from storage (real latency on large tables). Use to understand which filter values enable file skipping or to verify pruning effectiveness — not routinely.
Per-file bounds are binary-encoded in manifest Avro, not in the loadTable JSON. Three approaches fail before one works:
- DuckDB
parquet_metadata('<storage://path>')— bypasses the catalog secret, hits storage unauthenticated. 403 / access denied. - DuckDB
iceberg_metadata(...)— returns file paths and record counts, but notlower_bounds/upper_bounds. pyicebergstraight —table.scan().plan_files()exposesDataFile.lower_bounds/.upper_boundsdirectly and works for standard catalogs. It only breaks if your writer emits a custom puffin blob type pyiceberg doesn't recognize (e.g. Fivetran'sfivetran-synced-distribution), which fails Pydantic validation on table load. The HTTP-intercept below works around that.
The intercept is only needed for catalogs whose writer emits non-standard
puffin blobs (Fivetran MDLS is the known case). For Polaris, Lakekeeper, Unity,
Nessie, Glue, S3 Tables, etc., skip the patch.object(...) wrapper and call
plan_files() directly.
import json, struct, requests
from datetime import datetime, timezone
from unittest.mock import patch
# Standard puffin blob types pyiceberg understands. If your writer emits others,
# they get stripped here so table load succeeds. (Only needed for Fivetran MDLS.)
KNOWN_BLOBS = ("apache-datasketches-theta-v1", "deletion-vector-v1")
def _strip_unknown_blobs(resp):
if "tables" in resp.url and resp.status_code == 200:
try:
data = resp.json()
for s in data.get("metadata", {}).get("statistics", []):
s["blob-metadata"] = [b for b in s.get("blob-metadata", []) if b.get("type") in KNOWN_BLOBS]
resp._content = json.dumps(data).encode()
except Exception:
pass
return resp
original_get = requests.Session.get
def patched_get(self, url, **kwargs):
return _strip_unknown_blobs(original_get(self, url, **kwargs))
def decode_bound(typ, b):
if not b: return "(null)"
if typ in ("timestamptz", "timestamp"):
micros = struct.unpack_from("<q", b)[0]
return datetime.fromtimestamp(micros/1_000_000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
if typ == "boolean": return "true" if b == b'\x01' else "false"
if typ == "int": return str(struct.unpack_from("<i", b)[0])
if typ == "long": return str(struct.unpack_from("<q", b)[0])
if typ == "double": return str(struct.unpack_from("<d", b)[0])
try: return b.decode("utf-8").rstrip("\x00")
except: return repr(b)
def load_catalog_for_bounds():
from pyiceberg.catalog import load_catalog
# oauth2 / bearer:
return load_catalog("explorer", **{
"type": "rest",
"uri": "{catalog_uri}",
"warehouse": "{warehouse}",
"credential": "{oauth2_client_id}:{oauth2_client_secret}", # oauth2
# "token": "{token}", # bearer (use instead of credential)
"scope": "{oauth2_scope}", # omit if blank
})
# sigv4: swap the dict for the rest.sigv4-enabled / signing-name / signing-region form shown in Catalog Discovery.# Wrap in `with patch.object(requests.Session, "get", patched_get):` ONLY for Fivetran MDLS; otherwise run directly.
catalog = load_catalog_for_bounds()
table = catalog.load_table("<namespace>.<table>")
schema = table.schema()
field_map = {f.field_id: f.name for f in schema.fields}
type_map = {f.field_id: str(f.field_type) for f in schema.fields}
print(f"{'column':<25} {'type':<15} {'min':<40} {'max':<40}")
print("-"*120)
for task in table.scan().plan_files():
df = task.file
lo, hi = df.lower_bounds or {}, df.upper_bounds or {}
for fid in sorted(set(list(lo)+list(hi))):
typ = type_map.get(fid, "?")
print(f"{field_map.get(fid, fid):<25} {typ:<15} {decode_bound(typ, lo.get(fid, b'')):<40} {decode_bound(typ, hi.get(fid, b'')):<40}")FILTER_COLUMN = "<column you plan to filter on>"
FILTER_VALUE_STR = "<decoded value matching decode_bound output>"
catalog = load_catalog_for_bounds()
table = catalog.load_table("<namespace>.<table>")
schema = table.schema()
field_map = {f.field_id: f.name for f in schema.fields}
type_map = {f.field_id: str(f.field_type) for f in schema.fields}
from collections import defaultdict
col_mins, col_maxs, total = defaultdict(list), defaultdict(list), 0
for task in table.scan().plan_files():
df = task.file; total += 1
lo, hi = df.lower_bounds or {}, df.upper_bounds or {}
for fid in set(list(lo)+list(hi)):
typ = type_map.get(fid, "?")
col_mins[fid].append(decode_bound(typ, lo.get(fid, b"")))
col_maxs[fid].append(decode_bound(typ, hi.get(fid, b"")))
print(f"Total files: {total}\n")
print(f"{'column':<25} {'type':<15} {'global_min':<35} {'global_max':<35} {'skippable':>14}")
print("-"*128)
filter_fid = next((fid for fid, n in field_map.items() if n == FILTER_COLUMN), None)
for fid in sorted(col_mins):
name, typ = field_map.get(fid, f"field_{fid}"), type_map.get(fid, "?")
g_min, g_max = min(col_mins[fid]), max(col_maxs[fid])
skip = "-"
if fid == filter_fid:
skip = f"{sum(1 for mx in col_maxs[fid] if mx < FILTER_VALUE_STR)}/{total}"
print(f"{name:<25} {typ:<15} {g_min:<35} {g_max:<35} {skip:>14}")
print(f"\nFor WHERE {FILTER_COLUMN} >= '{FILTER_VALUE_STR}', DuckDB can skip files whose file_max < the filter value.")- Wide min↔max spread — good distribution; filtering skips many files.
- Narrow spread (min ≈ max) — clustered; filtering won't skip much.
- Boolean flag constant across files — filtering on it skips everything instantly for the opposite value.
- Skippable count — the core metric: e.g. 350/410 skippable means the real query reads 60 files, slashing
#GETand bytes.
| Problem | Solution |
|---|---|
duckdb: command not found |
Install from https://duckdb.org/docs/installation/ |
401 Unauthorized (oauth2) |
Token expired/stale — delete /tmp/iceberg_token_* and retry; the cache pattern re-acquires |
401 Unauthorized (bearer) |
token is wrong or expired — refresh it in .iceberg_config.json |
403/AccessDenied (sigv4) |
Check aws sts get-caller-identity, IAM perms for Glue/S3 Tables, and aws_region |
400 Bad Request on REST |
warehouse doesn't match the catalog prefix — confirm via GET /v1/config?warehouse=… |
404 on table metadata |
List namespaces/tables first — never guess names. Check nested-namespace %1F encoding |
DuckDB iceberg extension not found |
INSTALL iceberg FROM core; manually |
Secret "iceberg_secret"/"aws_secret" already exists |
Persistent secret already loaded. DROP PERSISTENT SECRET <name> if creds changed, then re-run first-time setup |
| Storage credential/region errors | Set storage_region (or aws_region for sigv4) correctly |
| GCS errors despite correct creds | Need DuckDB >=1.2 (GCS added then) |
| Glue/S3 Tables ATTACH fails | Need DuckDB >=1.3; confirm endpoint_type is glue/s3_tables and {warehouse} is the account id / table-bucket ARN |
| Azure / ADLS errors | DuckDB Iceberg ADLS support is evolving — check https://duckdb.org/docs/stable/core_extensions/iceberg/iceberg_rest_catalogs |
parquet_metadata() returns 403 |
Expected — it bypasses the catalog secret. Use iceberg_metadata() or pyiceberg |
pyiceberg ValidationError on a puffin blob (e.g. fivetran-synced-distribution) |
Your writer emits a non-standard blob type. Use the HTTP-intercept in Column Bounds Inspection to strip it before validation |
High #GET, small data |
Small-files problem — recommend compaction/OPTIMIZE, not a query rewrite |
These endpoints are not part of the Iceberg REST spec — they only work on specific catalogs. Use only when you know the backend.
GET {catalog_uri}/management/v1/catalogs/{warehouse}/catalogRoles # list catalog roles
GET {catalog_uri}/management/v1/principals # list principals
OAuth scope for Polaris-family catalogs is PRINCIPAL_ROLE:ALL; the token
endpoint is {catalog_uri}/v1/oauth/tokens.
A Polaris-backed oauth2 catalog. Conventions worth knowing:
- Audit columns:
_fivetran_synced,_fivetran_deleted, and (history mode)_fivetran_active,_fivetran_start,_fivetran_end. FilterWHERE _fivetran_deleted IS NOT TRUE; filter/sort on_fivetran_syncedfor cheap column-bound pruning. - Writes per-file bounds for all columns on tables ≤200 columns; for larger tables only
_fivetran_synced, primary keys, and history-mode columns. - Emits a custom
fivetran-synced-distributionpuffin blob — needs the HTTP-intercept in Column Bounds Inspection. - Hostname encodes region/provider, e.g.
pack-dictate.us-west-2.aws.polaris.fivetran.com→storage_region=us-west-2, provideraws. Setstorage_regionaccordingly.
sigv4 catalogs. The REST tier is awkward over curl — prefer pyiceberg
(rest.sigv4-enabled) or the aws glue / aws s3tables CLIs for metadata, and
DuckDB ENDPOINT_TYPE 'glue'/'s3_tables' for queries.
.iceberg_config.json— user-populated catalog credentials. Read from the skill base directory. Validate the fields required forauth_typeare non-empty before proceeding. Never overwrite it.