agentsclimarketplace

Trino iceberg

Skill ivanshamaev/de-agent-skills/skills/trino_iceberg

Профессиональные Data Engineering Agent Skills для разработки AI Agentic Data Platform

Install
npx -y skills add ivanshamaev/de-agent-skills --skill trino_iceberg

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

2 things to look at

  • no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
  • 13 stars13 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

Use when writing, optimizing, or maintaining Apache Iceberg tables with Trino — covering table DDL, all ALTER TABLE operations, partition transforms, bucketing, sorted tables, DML (INSERT/UPDATE/DELETE/MERGE), EXPLAIN plan reading, join optimization, statistics with ANALYZE, table maintenance (optimize/expire_snapshots/remove_orphan_files), schema evolution, time travel, and metadata table diagnostics.

SKILL.md

30.8 KB, ~7.6k tokens by cl100k_base, as published. Nobody here has run it

Trino + Apache Iceberg Engineer

When to Use

Use this skill when:

  • Creating or modifying Iceberg tables in Trino (DDL, partitioning, bucketing, table properties)
  • Writing or tuning DML: INSERT, UPDATE, DELETE, MERGE, TRUNCATE on Iceberg tables
  • Optimizing query performance: reading EXPLAIN plans, configuring join strategies, enabling pushdowns
  • Running or scheduling table maintenance: compaction, snapshot expiration, orphan file cleanup
  • Diagnosing performance problems using $partitions, $files, $snapshots metadata tables
  • Implementing schema evolution, time travel, or snapshot rollback
  • Configuring the Iceberg connector for a new Trino cluster

Core Workflow

Starting from scratch: new table

  1. Choose partition granularity from volume guidelines (see Partitioning section).
  2. Decide on bucketing if a high-cardinality join/filter column exists (see Bucketing section).
  3. Pick sorted_by columns: the highest-selectivity filter columns and join keys.
  4. Create the table with format='PARQUET', format_version=2.
  5. Load data, then run ANALYZE on join and filter columns.
  6. Verify the plan with EXPLAIN (TYPE DISTRIBUTED).

Diagnosing a slow query

  1. Run EXPLAIN (TYPE DISTRIBUTED) — identify join type, exchange type, scan rows.
  2. Run SHOW STATS FOR <table> — check if statistics exist.
  3. Check "$partitions" — look for partition skew or small-file accumulation.
  4. Run ANALYZE if NDV / row counts are missing.
  5. Adjust partitioning, bucketing, sorted_by, or session join properties if plan is still suboptimal.

Regular maintenance cycle

Daily   → OPTIMIZE on partitions written in the last 1–2 days
Weekly  → EXPIRE_SNAPSHOTS + REMOVE_ORPHAN_FILES + OPTIMIZE_MANIFESTS
After optimize → ANALYZE to refresh statistics

Table Creation

Column data types

CategoryTypes
IntegerBOOLEAN, TINYINT, SMALLINT, INTEGER / INT, BIGINT
Floating pointREAL / FLOAT, DOUBLE
Fixed precisionDECIMAL(precision, scale) — use for monetary values
StringVARCHAR (unbounded), VARCHAR(n) (max n chars), CHAR(n) (fixed, space-padded)
BinaryVARBINARY
Date / timeDATE, TIME, TIME(p) WITH TIME ZONE, TIMESTAMP, TIMESTAMP(p) WITH TIME ZONE
ComplexARRAY(element_type), MAP(key_type, value_type), ROW(field_name field_type, ...)
OtherUUID, JSON, IPADDRESS

Prefer TIMESTAMP(6) WITH TIME ZONE over bare TIMESTAMP — Iceberg stores time zone offset and avoids daylight saving ambiguity.

Full CREATE TABLE syntax

CREATE TABLE iceberg_catalog.mydb.events (
    event_id    BIGINT        NOT NULL,
    event_type  VARCHAR       NOT NULL,
    user_id     BIGINT,
    region      VARCHAR,
    amount      DECIMAL(18,2),
    tags        ARRAY(VARCHAR),
    metadata    MAP(VARCHAR, VARCHAR),
    address     ROW(city VARCHAR, country CHAR(2)),
    event_ts    TIMESTAMP(6) WITH TIME ZONE NOT NULL,
    created_at  TIMESTAMP(6) WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)
COMMENT 'Raw clickstream events'
WITH (
    format                         = 'PARQUET',
    format_version                 = 2,
    partitioning                   = ARRAY['region', 'day(event_ts)'],
    sorted_by                      = ARRAY['user_id', 'event_type'],
    write_target_data_file_size_bytes = 536870912,  -- 512 MB per file
    location                       = 's3://my-bucket/warehouse/mydb/events'
);

All WITH table properties

PropertyValues / DefaultDescription
format'PARQUET' ★ / 'ORC' / 'AVRO'Data file format. Use PARQUET for analytics.
format_version1 / 2Format v2 required for full ACID (UPDATE/DELETE/MERGE).
partitioningARRAY[...]Partition spec (transforms). See Partitioning section.
sorted_byARRAY[...]Sort columns within each data file.
locationURI stringStorage path. Defaults to catalog warehouse path.
write_target_data_file_size_bytesinteger, default 1073741824 (1 GB)Max data file size before rolling to a new file.
orc_bloom_filter_columnsARRAY['col1', 'col2']ORC-only: enables Bloom filters for these columns.
orc_bloom_filter_fpp0.0–1.0, default 0.05ORC Bloom filter false-positive probability.
parquet_bloom_filter_columnsARRAY['col1', 'col2']Parquet: Bloom filters for equality / IN predicates.

★ = recommended default.

Connector defaults (etc/catalog/iceberg.properties)

iceberg.file-format=PARQUET
iceberg.compression-codec=ZSTD
iceberg.target-max-file-size=1GB
iceberg.sorted-writing-enabled=true
iceberg.table-statistics-enabled=true
iceberg.file-system-cache.enabled=true
iceberg.file-system-cache.directory=/tmp/trino-cache
iceberg.file-system-cache.max-size=100GB

Set these once globally; omit format and compression from individual table DDL unless overriding.

CREATE TABLE AS SELECT (CTAS)

-- Create and populate in one step; inherits column types from the SELECT
CREATE TABLE iceberg_catalog.mydb.events_daily
WITH (
    format         = 'PARQUET',
    format_version = 2,
    partitioning   = ARRAY['dt']
) AS
SELECT
    date_trunc('day', event_ts) AS dt,
    event_type,
    region,
    count(*)        AS event_count,
    sum(amount)     AS total_amount
FROM iceberg_catalog.mydb.events
GROUP BY 1, 2, 3;

CTAS creates a new snapshot; run ANALYZE afterwards if the table will be used in joins.

CREATE TABLE LIKE

-- Copy column definitions and table properties; does NOT copy data
CREATE TABLE iceberg_catalog.mydb.events_staging
LIKE iceberg_catalog.mydb.events
INCLUDING PROPERTIES;   -- copies format, partitioning, sorted_by, etc.

Use EXCLUDING PROPERTIES to copy only columns without the partition spec (useful for staging tables that shouldn't be partitioned the same way).

External / registered table

Point to existing Iceberg metadata already on object storage:

CREATE TABLE iceberg_catalog.mydb.events_external (
    event_id   BIGINT,
    event_ts   TIMESTAMP(6) WITH TIME ZONE
)
WITH (
    location = 's3://existing-bucket/path/to/table'
);

Trino will read the existing metadata/ directory at that location and register it.


Partitioning

Partition transforms

TransformSyntaxUse case
IdentityregionLow-cardinality categorical columns
Dayday(ts)Daily pipelines — most common temporal choice
Hourhour(ts)High-frequency event streams (> 5 GB/day)
Monthmonth(ts)Low-volume data (< 100 MB/day)
Yearyear(ts)Archival or very sparse data
Bucketbucket(N, col)High-cardinality IDs — see Bucketing section
Truncatetruncate(N, col)String prefix or integer range partitioning

Hidden partitioning: users query the original column (event_ts, user_id). Trino rewrites predicates into the corresponding partition transform internally. The partition column never leaks into the schema.

Partition sizing guideline

Daily data volumeRecommended granularity
< 100 MBmonth(ts)
100 MB – 5 GBday(ts)
5 GB – 50 GBhour(ts)
> 50 GBhour(ts) + bucket(N, high_card_col)

Target 256 MB – 1 GB per data file after compaction.

Combined partition specs

-- Temporal + categorical
partitioning = ARRAY['day(event_ts)', 'region']

-- Temporal + bucket for even distribution per day
partitioning = ARRAY['day(event_ts)', 'bucket(32, user_id)']

-- Truncate for string prefix partitioning
partitioning = ARRAY['truncate(2, country_code)', 'month(event_ts)']

-- Identity on enum
partitioning = ARRAY['status', 'day(created_at)']

How partition pruning works

  1. The optimizer extracts predicates on partition columns from the WHERE clause.
  2. Each predicate is translated to the partition transform and matched against manifest file metadata.
  3. Manifest files whose partition range doesn't match are skipped without reading any data.
  4. Within surviving manifests, per-file min/max statistics skip individual data files.
-- This predicate prunes day(event_ts) partitions — only Jan files are read
SELECT * FROM events
WHERE event_ts >= TIMESTAMP '2024-01-01 00:00:00 UTC'
  AND event_ts <  TIMESTAMP '2024-02-01 00:00:00 UTC';

-- Combined pruning: day + region both applied
SELECT * FROM events
WHERE event_ts >= TIMESTAMP '2024-01-15 00:00:00 UTC'
  AND region = 'us-east-1';

Pruning works for: equality (=), ranges (>, <, BETWEEN), IN lists, IS NULL / IS NOT NULL. Pruning does NOT work for: expressions on columns (date_trunc('day', event_ts) = DATE '2024-01-15' — use the raw column), OR across partition columns.

Verify pruning in EXPLAIN

EXPLAIN (TYPE IO, FORMAT JSON)
SELECT count(*) FROM events
WHERE event_ts >= TIMESTAMP '2024-01-15 00:00:00 UTC'
  AND region = 'us-east-1';
-- Look for: "partitionsCount": N  (low N means pruning worked)
-- and "estimatedSizeInBytes" matching only the selected partitions

Inspect current partition spec

-- File distribution per partition
SELECT partition, file_count, record_count,
       total_size / 1024 / 1024 AS total_mb,
       total_size / NULLIF(file_count, 0) / 1024 / 1024 AS avg_file_mb
FROM iceberg_catalog.mydb."events$partitions"
ORDER BY total_size DESC;

-- Table properties including current partition spec
SELECT * FROM iceberg_catalog.mydb."events$properties";

Evolving partitioning (no rewrite)

-- Finer granularity going forward; existing files keep old layout
ALTER TABLE events
SET PROPERTIES partitioning = ARRAY['region', 'hour(event_ts)'];

-- Add a bucket dimension to an existing temporal partition
ALTER TABLE events
SET PROPERTIES partitioning = ARRAY['day(event_ts)', 'bucket(32, user_id)'];

-- Remove partitioning entirely (unpartitioned going forward)
ALTER TABLE events
SET PROPERTIES partitioning = ARRAY[];

Old files are not rewritten and remain queryable under both layouts.


Bucketing

What bucketing does

bucket(N, col) hashes the column value and assigns it to one of N bucket numbers. Bucket number is stored in partition metadata. At query time, equality predicates on the bucketed column are translated to specific bucket numbers — only manifest files for those buckets are read.

row with user_id=12345
  → hash(12345) mod 32 = 17
  → written to bucket 17 file
  → query WHERE user_id = 12345 → reads only bucket 17 file

When to use bucketing

Use bucket(N, col) when:

  • The column has high cardinality (millions+ distinct values: user_id, order_id, session_id).
  • Queries frequently filter by equality or IN-list on that column.
  • A temporal partition alone would create too many tiny files per partition, or without bucketing each partition file would be too large.
  • The table participates in joins on that column — co-located bucketed tables joined on the same column avoid network shuffle.

Do not use bucketing on low-cardinality columns (< 1000 distinct values) — use identity partitioning or truncate instead.

Choosing bucket count N

Start with: N = ceil(expected_partition_size_GB / 0.5) — aiming for ~512 MB per bucket file per partition.

Partition size after loadSuggested N
< 4 GB/day8
4–16 GB/day32
16–64 GB/day64
64–256 GB/day128
> 256 GB/day256+

N must be a power of 2 — Iceberg's hash function relies on this for consistent distribution. Changing N later requires a full table rewrite, so choose carefully upfront.

Create a bucketed table

-- Pure bucket partitioning (no temporal — suited for lookup tables)
CREATE TABLE iceberg_catalog.mydb.user_profiles (
    user_id     BIGINT NOT NULL,
    email       VARCHAR,
    country     CHAR(2),
    created_at  TIMESTAMP(6) WITH TIME ZONE
)
WITH (
    format         = 'PARQUET',
    format_version = 2,
    partitioning   = ARRAY['bucket(64, user_id)'],
    sorted_by      = ARRAY['user_id']
);

-- Combined temporal + bucket (most common production pattern)
CREATE TABLE iceberg_catalog.mydb.events (
    event_id    BIGINT,
    user_id     BIGINT,
    event_ts    TIMESTAMP(6) WITH TIME ZONE,
    event_type  VARCHAR,
    payload     VARCHAR
)
WITH (
    format         = 'PARQUET',
    format_version = 2,
    partitioning   = ARRAY['day(event_ts)', 'bucket(32, user_id)'],
    sorted_by      = ARRAY['user_id', 'event_type']
);

Bucket pruning mechanics

Trino translates equality and IN predicates into bucket numbers and skips all other bucket files:

-- Equality → looks up exactly bucket hash(42) mod 32
SELECT * FROM events WHERE user_id = 42;

-- IN list → reads only the buckets for each value in the list
SELECT * FROM events WHERE user_id IN (42, 1001, 9999);

-- Range predicate → cannot compute bucket set → reads ALL buckets (no pruning)
SELECT * FROM events WHERE user_id BETWEEN 1 AND 1000;

Verify bucket pruning via EXPLAIN IO:

EXPLAIN (TYPE IO, FORMAT JSON)
SELECT * FROM events WHERE user_id = 42 AND event_ts >= TIMESTAMP '2024-01-01 00:00:00 UTC';
-- Expected: partitionsCount = 1 (only the bucket containing user_id=42 within the day partition)

Bucket pruning through joins (dynamic partition pruning)

When a bucketed table is joined to a small filtered dimension, Trino builds a Bloom filter from the dimension side and pushes it to the bucket scan — further reducing files read at runtime:

-- Trino builds a Bloom filter from the WHERE on dim_users and pushes it
-- to the events scan, skipping bucket files not containing matching user_ids
SELECT e.event_type, count(*)
FROM events e
JOIN dim_users u ON e.user_id = u.user_id
WHERE u.country = 'US'
GROUP BY 1;

EXPLAIN shows DynamicFilterSource on the build side and DynamicFilter applied to the scan — this is working as expected.

Adding bucketing to an existing table

-- Affects future writes and OPTIMIZE only; existing files are not changed
ALTER TABLE events
SET PROPERTIES partitioning = ARRAY['day(event_ts)', 'bucket(32, user_id)'];

After changing the partition spec: run OPTIMIZE to compact and re-bucket existing files, then ANALYZE.

Changing bucket count

There is no in-place bucket count change — existing files are tied to the original bucket mapping. To change N:

-- 1. Create new table with desired bucket count
CREATE TABLE events_v2 AS SELECT * FROM events;  -- CTAS with new WITH properties

-- 2. Swap alias / rename (coordinate with consumers)
ALTER TABLE events RENAME TO events_old;
ALTER TABLE events_v2 RENAME TO events;

ALTER TABLE: Full Reference

Column operations

-- Add a nullable column (visible as NULL for existing rows)
ALTER TABLE events ADD COLUMN session_id VARCHAR;

-- Add with NOT NULL constraint (only safe if table is empty or you provide a DEFAULT)
ALTER TABLE events ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT false;

-- Add a column with a comment
ALTER TABLE events ADD COLUMN campaign_id BIGINT COMMENT 'Ad campaign identifier';

-- Add a column and position it explicitly
ALTER TABLE events ADD COLUMN channel VARCHAR FIRST;
ALTER TABLE events ADD COLUMN channel VARCHAR AFTER event_type;

-- Drop a column (data in existing files is preserved but invisible to queries)
ALTER TABLE events DROP COLUMN legacy_field;

-- Rename a column (tracked by column ID — safe for time travel reads)
ALTER TABLE events RENAME COLUMN user_id TO account_id;

-- Widen a column type (only safe promotions allowed)
ALTER TABLE events ALTER COLUMN click_count SET DATA TYPE BIGINT;   -- INT → BIGINT
ALTER TABLE events ALTER COLUMN score      SET DATA TYPE DOUBLE;    -- FLOAT → DOUBLE

-- Change nullability
ALTER TABLE events ALTER COLUMN session_id SET NOT NULL;
ALTER TABLE events ALTER COLUMN session_id DROP NOT NULL;

-- Set or change default value
ALTER TABLE events ALTER COLUMN status SET DEFAULT 'active';
ALTER TABLE events ALTER COLUMN status DROP DEFAULT;

-- Reorder an existing column
ALTER TABLE events ALTER COLUMN session_id FIRST;
ALTER TABLE events ALTER COLUMN session_id AFTER event_id;

-- Add or change column comment
ALTER TABLE events ALTER COLUMN user_id COMMENT 'Internal account ID';

Nested column operations (ROW fields)

-- Add a subfield to an existing ROW column
ALTER TABLE events ADD COLUMN address.zip_code VARCHAR;

-- Drop a subfield
ALTER TABLE events DROP COLUMN address.zip_code;

-- Rename a subfield
ALTER TABLE events RENAME COLUMN address.city TO address.city_name;

Allowed type promotions

FromToSafe
TINYINTSMALLINT, INTEGER, BIGINTYes
SMALLINTINTEGER, BIGINTYes
INTEGERBIGINTYes
FLOAT / REALDOUBLEYes
DECIMAL(p, s)DECIMAL(p', s) where p' > pYes
VARCHAR(n)VARCHAR(m) where m > n or unbounded VARCHARYes
Any typeIncompatible typeNo — requires table rebuild

Table property changes

-- Change partition spec (in-place; old files keep old layout)
ALTER TABLE events SET PROPERTIES partitioning = ARRAY['day(event_ts)', 'bucket(32, user_id)'];

-- Remove partitioning
ALTER TABLE events SET PROPERTIES partitioning = ARRAY[];

-- Change or add sorted_by (affects future writes and OPTIMIZE)
ALTER TABLE events SET PROPERTIES sorted_by = ARRAY['user_id', 'event_type'];

-- Remove sorting
ALTER TABLE events SET PROPERTIES sorted_by = ARRAY[];

-- Upgrade format version (v1 → v2 enables full ACID)
ALTER TABLE events SET PROPERTIES format_version = 2;

-- Change target file size for writes (bytes)
ALTER TABLE events SET PROPERTIES write_target_data_file_size_bytes = 268435456;  -- 256 MB

-- Enable Parquet Bloom filters on specific columns
ALTER TABLE events SET PROPERTIES parquet_bloom_filter_columns = ARRAY['user_id', 'session_id'];

-- Enable ORC Bloom filters
ALTER TABLE events SET PROPERTIES orc_bloom_filter_columns = ARRAY['user_id'];
ALTER TABLE events SET PROPERTIES orc_bloom_filter_fpp     = 0.01;

Table rename

ALTER TABLE iceberg_catalog.mydb.events RENAME TO iceberg_catalog.mydb.events_v2;

Table comment

COMMENT ON TABLE events IS 'Raw clickstream events — partitioned by day and user bucket';
COMMENT ON COLUMN events.user_id IS 'Internal account identifier';

Change table owner

ALTER TABLE events SET AUTHORIZATION USER 'new_owner';
ALTER TABLE events SET AUTHORIZATION ROLE 'data_engineering';

Table procedures (via ALTER TABLE EXECUTE)

These are covered in detail in Table Maintenance, but listed here for completeness:

ALTER TABLE events EXECUTE optimize(file_size_threshold => '100MB');
ALTER TABLE events EXECUTE expire_snapshots(retention_threshold => '14d', retain_last => 10);
ALTER TABLE events EXECUTE remove_orphan_files(retention_threshold => '14d');
ALTER TABLE events EXECUTE optimize_manifests;

Drop table

-- Drops table metadata AND data files
DROP TABLE iceberg_catalog.mydb.events;

-- Safe drop (no error if table doesn't exist)
DROP TABLE IF EXISTS iceberg_catalog.mydb.events;

Sorted Tables

sorted_by pre-sorts rows by the given columns within each data file. Benefits:

  • Tighter min/max bounds in manifests → more file skipping for selective predicates.
  • Higher compression ratios (similar values are adjacent).

A 1.4B-row event table sorted on (user_id, event_type) shrank from 8.09 GB / 3,619 files to 2.40 GB / 1,074 files (73% reduction).

Rule: set sorted_by on columns most used in WHERE equality filters and join predicates. Primary sort column should be the one with the highest cardinality in those filters.

-- Set at creation
CREATE TABLE events (...) WITH (
    partitioning = ARRAY['day(event_ts)'],
    sorted_by    = ARRAY['user_id', 'event_type']
);

-- Add to existing table (affects future writes and OPTIMIZE)
ALTER TABLE events SET PROPERTIES sorted_by = ARRAY['user_id', 'event_type'];

After adding sorted_by, run OPTIMIZE to compact and re-sort existing files, then ANALYZE.


EXPLAIN: Reading the Plan

-- Distributed plan: stages, join types, exchanges
EXPLAIN (TYPE DISTRIBUTED) SELECT ...;

-- IO plan: scan row estimates used by optimizer (JSON for tooling)
EXPLAIN (TYPE IO, FORMAT JSON) SELECT ...;

-- Actual runtime stats (runs the query)
EXPLAIN ANALYZE SELECT ...;

Key signals

SignalMeaningAction
InnerJoin[BROADCAST]Small table replicated to all workersExpected for small dims; verify size estimate
InnerJoin[PARTITIONED]Both sides hash-shuffledNormal for large–large joins
LocalExchange[GATHER]Single-threaded aggregationCheck partition key or add parallelism
ScanFilterProject with high estimated rowsPoor pruning or missing statsAdd partition predicate; run ANALYZE
DynamicFilterSourceBuild-side Bloom filter pushed to scanGood — reduces probe scan at runtime
low partitionsCount in IO planPartition pruning workingConfirm expected count
high partitionsCount despite filterPredicate not pruningCheck column is a partition transform; avoid expressions

Statistics (ANALYZE)

Manifest files automatically track min/max per file — no ANALYZE needed for file-level skipping. ANALYZE additionally computes table-level row counts, null fractions, and NDV (required for join cardinality estimation and join reordering).

-- Full table
ANALYZE iceberg_catalog.mydb.events;

-- Specific columns only (faster, target join and filter columns)
ANALYZE iceberg_catalog.mydb.events
  WITH (columns = ARRAY['user_id', 'event_type', 'region']);

-- Single partition (incremental refresh)
ANALYZE iceberg_catalog.mydb.events
  WITH (partitioning = ARRAY['dt=2024-01-15']);

-- Inspect current stats
SHOW STATS FOR iceberg_catalog.mydb.events;

Run ANALYZE after: initial load, bulk DML > 10% of table, OPTIMIZE, or when EXPLAIN shows wrong row estimates.


Join Optimization

SET SESSION join_reordering_strategy       = 'AUTOMATIC';   -- CBO reorders with stats
SET SESSION join_distribution_type         = 'AUTOMATIC';   -- CBO chooses broadcast vs partitioned
SET SESSION join_max_broadcast_table_size  = '200MB';       -- default 100 MB

Force broadcast when CBO underestimates the build side:

SELECT /*+ BROADCAST(small_dim) */ f.*, d.name
FROM large_fact f
JOIN small_dim d ON f.dim_id = d.id;

Enable adaptive join reordering (corrects estimates mid-execution):

fault-tolerant-execution-enabled=true
fault-tolerant-execution-adaptive-query-planning-enabled=true

DML

INSERT

INSERT INTO events SELECT * FROM staging_events;
INSERT INTO events (event_id, event_type, user_id, event_ts) VALUES (1, 'click', 42, CURRENT_TIMESTAMP);

UPDATE (copy-on-write)

Trino rewrites affected data files entirely. Align the WHERE predicate with partition columns to minimize rewritten files:

UPDATE users
SET status = 'inactive'
WHERE region = 'us-east-1'
  AND last_login_ts < TIMESTAMP '2023-01-01 00:00:00 UTC';

DELETE

DELETE FROM events WHERE dt = DATE '2024-01-14';                    -- partition-aligned: rewrites minimal files
DELETE FROM events WHERE event_type = 'test' AND dt < DATE '2024-01-01';

MERGE — canonical patterns

Conditional upsert:

MERGE INTO users t
USING user_updates s ON t.user_id = s.user_id
WHEN MATCHED AND s.updated_at > t.updated_at
  THEN UPDATE SET email = s.email, status = s.status, updated_at = s.updated_at
WHEN NOT MATCHED
  THEN INSERT (user_id, email, status, updated_at)
       VALUES (s.user_id, s.email, s.status, s.updated_at);

SCD Type 2:

MERGE INTO customers t
USING customer_updates s
  ON t.customer_id = s.customer_id AND t.is_current = true
WHEN MATCHED AND s.updated_at > t.updated_at THEN
  UPDATE SET is_current = false, expiry_date = s.updated_at
WHEN NOT MATCHED THEN
  INSERT (customer_id, name, email, effective_date, expiry_date, is_current)
  VALUES (s.customer_id, s.name, s.email, s.updated_at, null, true);

Insert-only (dedup load):

MERGE INTO target t
USING staging s ON t.id = s.id
WHEN NOT MATCHED THEN INSERT VALUES (s.id, s.val, s.updated_at);

TRUNCATE

TRUNCATE TABLE events;  -- instant; creates empty snapshot without file rewrites

Table Maintenance

Run in this order. Never skip REMOVE_ORPHAN_FILES after EXPIRE_SNAPSHOTS.

1. OPTIMIZE (compaction)

ALTER TABLE events EXECUTE optimize(file_size_threshold => '100MB');

-- Scope to recent partitions
ALTER TABLE events EXECUTE optimize
WHERE event_ts >= CURRENT_TIMESTAMP - INTERVAL '2' DAY;

-- Scope by file modification time
ALTER TABLE events EXECUTE optimize
WHERE "$file_modified_time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY;

2. EXPIRE_SNAPSHOTS

ALTER TABLE events EXECUTE expire_snapshots(
    retention_threshold          => '14d',
    retain_last                  => 10,
    clean_expired_metadata_files => true
);

3. REMOVE_ORPHAN_FILES

retention_threshold must be ≥ your cluster's query.max-run-time:

ALTER TABLE events EXECUTE remove_orphan_files(retention_threshold => '14d');

4. OPTIMIZE_MANIFESTS

ALTER TABLE events EXECUTE optimize_manifests;

5. ANALYZE (after any optimize)

ANALYZE iceberg_catalog.mydb.events;

Recommended schedule

-- Daily: compact recent partitions only
ALTER TABLE events EXECUTE optimize
WHERE event_ts >= CURRENT_TIMESTAMP - INTERVAL '2' DAY;

-- Weekly:
ALTER TABLE events EXECUTE expire_snapshots(
    retention_threshold => '14d', retain_last => 10,
    clean_expired_metadata_files => true
);
ALTER TABLE events EXECUTE remove_orphan_files(retention_threshold => '14d');
ALTER TABLE events EXECUTE optimize_manifests;
ANALYZE iceberg_catalog.mydb.events;

Time Travel and Rollback

-- Query a past snapshot by ID
SELECT * FROM events FOR VERSION AS OF 1234567890;

-- Query the state at a timestamp
SELECT * FROM events FOR TIMESTAMP AS OF TIMESTAMP '2024-01-14 12:00:00 UTC';

-- Daily delta
SELECT new.cnt - old.cnt AS delta
FROM (SELECT count(*) AS cnt FROM events) new,
     (SELECT count(*) AS cnt FROM events
      FOR TIMESTAMP AS OF CURRENT_TIMESTAMP - INTERVAL '1' DAY) old;

-- Roll back to a snapshot (creates a new snapshot)
CALL iceberg_catalog.system.rollback_to_snapshot(
    schema_name => 'mydb', table_name => 'events', snapshot_id => 1234567890
);
CALL iceberg_catalog.system.rollback_to_timestamp(
    schema_name => 'mydb', table_name => 'events',
    timestamp   => TIMESTAMP '2024-01-14 12:00:00.000 UTC'
);

List snapshots:

SELECT snapshot_id, committed_at, operation, summary
FROM iceberg_catalog.mydb."events$snapshots"
ORDER BY committed_at DESC LIMIT 10;

Metadata Tables

Metadata tableContents
"t$snapshots"Snapshot log: IDs, timestamps, operations
"t$history"Schema/partition/property change log
"t$files"All data files in current snapshot with sizes and column stats
"t$manifests"Manifest files: paths, lengths, partition summaries
"t$partitions"Per-partition: file count, record count, total size
"t$refs"Named branches and tags
"t$properties"Table properties key/value

Diagnostic queries

-- Partitions with too many small files → candidates for OPTIMIZE
SELECT partition, file_count,
       total_size / 1024 / 1024 AS total_mb,
       total_size / NULLIF(file_count, 0) / 1024 / 1024 AS avg_file_mb
FROM iceberg_catalog.mydb."events$partitions"
WHERE total_size / NULLIF(file_count, 0) < 64 * 1024 * 1024
ORDER BY file_count DESC;

-- Largest partitions
SELECT partition, record_count,
       total_size / 1024.0 / 1024 / 1024 AS total_gb
FROM iceberg_catalog.mydb."events$partitions"
ORDER BY total_size DESC LIMIT 20;

-- Snapshot growth rate
SELECT committed_at, operation, added_files_count, deleted_files_count
FROM iceberg_catalog.mydb."events$snapshots"
ORDER BY committed_at DESC LIMIT 20;

Anti-Patterns

Anti-PatternProblemFix
SELECT * on wide tablesReads all columns; defeats columnar pushdownEnumerate required columns
No ANALYZE after large loadsDefault row estimates → bad join orderRun ANALYZE after every bulk DML
OPTIMIZE without ANALYZE afterStatistics reflect old file layoutAlways run ANALYZE after OPTIMIZE
EXPIRE_SNAPSHOTS without REMOVE_ORPHAN_FILESOrphan files accumulateAlways pair the two procedures
retention_threshold < longest queryRunning queries read deleted filesSet threshold ≥ query.max-run-time
hour(ts) on low-volume tablesMillions of tiny filesUse day(ts) or month(ts)
Bucket count N not a power of 2Uneven distributionAlways use powers of 2: 8, 16, 32, 64, 128...
Bucket count too highToo many small files per partitionAim for ~512 MB per bucket file
Range predicate on bucketed columnNo bucket pruning — reads all bucketsUse equality or IN; for ranges, add a temporal partition
MERGE/UPDATE without partition predicateFull-table COW rewriteInclude partition column in WHERE
AVRO for analytics tablesRow-oriented; no column skippingUse PARQUET for all analytics tables
Skipping OPTIMIZE indefinitelySmall files accumulate → slow manifest scansRun daily on recent partitions
Changing bucket count in-placeNot possible — old files use old bucket mappingCTAS into new table with desired N, then rename
Using expressions on partition columns in WHEREPrevents predicate pushdown to partition pruningUse raw column: event_ts >= ... not date_trunc('day', event_ts) = ...

Output Expectations

When working on Trino + Iceberg tasks:

  • Show the EXPLAIN (TYPE DISTRIBUTED) excerpt highlighting the problem (join type, estimated rows, exchange type).
  • Propose specific CREATE TABLE DDL or ALTER TABLE SET PROPERTIES with explicit partitioning, sorted_by, and format_version.
  • For bucketing questions: state the chosen N, explain why, show how to verify pruning with EXPLAIN (TYPE IO).
  • Name maintenance commands in order and explain why order matters.
  • State when ANALYZE is needed and which columns to include.
  • Provide the $partitions diagnostic query to validate the fix after implementation.

References

What ships with it

Read from the repository

Just SKILL.md. No reference files, no scripts.

Gives 0 of the 12 instructions most performance cost skills give in ~7.6k tokens

Counted across 803 of the 1,058 authors here whose files we hold, read 2026-08-07

  • Keep skill files under 500 lines or tokensin 82 of 803, across 16 files
  • Use imperative form in instructionsin 80 of 803, across 9 files
  • Draft assertions while test runs are in progressin 75 of 803, across 9 files
  • Create two to three realistic test promptsin 74 of 803, across 9 files
  • Write skill descriptions to be pushyin 72 of 803, across 7 files
  • Save test cases to evals JSONin 72 of 803, across 6 files
  • Ask questions about edge cases and input formatsin 72 of 803, across 7 files
  • Save timing data immediately when runs completein 70 of 803, across 5 files
  • Include all trigger conditions in the skill descriptionin 69 of 803, across 3 files
  • Launch all test runs in a single turn or simultaneouslyin 69 of 803, across 3 files
  • Capture intent before writing a skillin 67 of 803, across 1 file
  • Import directly instead of barrel filesin 52 of 803, across 15 files

Said here and by no other author read

  • Use PARQUET format and format_version 2 for tables
  • Use bucketing for high-cardinality join columns
  • Sort data by high-selectivity filter columns
  • Choose partition granularity by data volume
  • Prefer TIMESTAMP WITH TIME ZONE over TIMESTAMP
  • Execute OPTIMIZE on recently written partitions daily

Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.