On September 30, AWS announced that Aurora PostgreSQL can now query Apache Iceberg and Parquet data directly. That brings the number of things a vendor might mean by “Postgres reads Iceberg” to at least four, and they have about as much in common as the four things a vendor might mean by “serverless.”
The four: Aurora’s new aurora_analytics extension; pg_lake from Snowflake; ColdFront from pgEdge; and pg_clickhouse from ClickHouse. The first three use the same engine, DuckDB, to read Parquet out of object storage. The fourth does not read Iceberg at all, which we’ll get to. Even the three that share an engine are not interchangeable, and the feature matrix won’t show you why. What separates them is the answer to one question: when the query runs, which planner is in charge of it?
A caveat before the tour. Everything here comes from each project’s own documentation as of October 2, 2026. I have not run these side by side.
Two planners, one query
PostgreSQL’s planner makes decisions from statistics: pg_class.reltuples, pg_statistic, the histograms and most-common-value lists that ANALYZE builds. A Parquet file in S3 has none of those. It has its own metadata (row counts and min/max values in the footer; Iceberg adds per-file record counts and column bounds in its manifests), and that metadata is quite good, but PostgreSQL does not know how to read it. The engine that does know how to read it has its own planner.
So each of these systems has two planners in it, and the design decision is which one gets the query. The lake engine can take the entire statement, heap tables included. PostgreSQL can keep the statement and hand the lake engine a scan. Or PostgreSQL can treat the lake engine as a remote server and negotiate through the foreign data wrapper API. The interesting case is always the same one: a join between a heap table and a Parquet file.
Aurora: DuckDB in the building
Aurora’s version is available on Aurora PostgreSQL 17.11 and later, and 18.6 and later. You set aurora_analytics.enabled in the cluster parameter group, attach an IAM role with the AuroraAnalytics feature, run CREATE EXTENSION aurora_analytics, and create foreign tables:
1 CREATE FOREIGN TABLE transaction_history ()
2 SERVER aurora_analytics_server
3 OPTIONS (
4 location 's3://my-bucket/finance/transaction_history.parquet',
5 format 'parquet'
6 );
The empty column list is deliberate; the schema comes from the file. Sources are the AWS Glue Data Catalog, plain S3, and S3 Tables, plus any Iceberg REST catalog you federate through Glue. The tables are read-only. No INSERT, UPDATE, DELETE, TRUNCATE, or COPY FROM; if you want lake data in a heap table, you materialize it with CREATE TABLE AS.
The announcement is a marketing post, and its one worked example is a UNION ALL of a heap table and a Parquet file, which is the easiest cross-tier query there is, since the two halves never meet. Skip it and read the User Guide, which is far better than the announcement deserves.
DuckDB is embedded in the PostgreSQL server; AWS’s phrase is that hybrid queries “run within a single process” and see your session’s uncommitted writes. PostgreSQL parses and plans, then delegates. There are two outcomes, and the execution plan page shows both.
In full query pushdown, the whole statement goes to DuckDB and the plan is a single Custom Scan. This includes heap tables. The guide’s example joins a Parquet foreign table to a local table, and the plan has a DuckDB HASH_JOIN over a READ_PARQUET and a POSTGRES_SCAN: DuckDB reads the heap itself and does the join. In table-scan pushdown, the fallback, DuckDB gets only the foreign table scan (with its filters and column list), and PostgreSQL does the joins, sorts, and everything else.
What decides between them is not cost. Per the guide, full pushdown is prevented only when the query references something DuckDB can’t run: an unsupported function, data type, or collation. EXPLAIN (VERBOSE) prints the reason under Unsupported Pushdown Expressions. The examples are instructive. soundex() blocks it, which is fair. So does a local table with a character(25) column, which is less fair. So does a non-C collation on a comparison expression; the documented fix is to add COLLATE "C". Go check what your database’s default collation is. I’ll wait.
So on Aurora, one char(n) column in a dimension table moves your join from DuckDB’s planner to PostgreSQL’s, silently, and the only place it says so is the bottom of the EXPLAIN output.
pg_lake: DuckDB next door
pg_lake began at Crunchy Data in early 2024, shipped as Crunchy Bridge for Analytics and then Crunchy Data Warehouse, and was open-sourced by Snowflake in November 2025 after the acquisition.
It deliberately does not embed DuckDB. A pg_lake installation is PostgreSQL with a set of extensions, plus a separate multi-threaded process called pgduck_server that speaks the PostgreSQL wire protocol over a local Unix socket. (It listens on port 5332; you can connect to it with psql and talk to DuckDB directly, which is a nice trick.) The project’s stated reason is that embedding a multi-threaded engine in a PostgreSQL backend runs into the threading and memory-safety limits of a server built around process isolation. Its memory limit defaults to 80% of system memory, which you will want to change on a machine that also has shared_buffers.
The pushdown model is the same two-outcome shape as Aurora’s, and EXPLAIN (VERBOSE) reports it the same way. If everything can be expressed in DuckDB, the plan is one Custom Scan (Query Pushdown) node. If something can’t (the documentation’s example is width_bucket()), you get a Foreign Scan, PostgreSQL executes the rest, and the plan ends with a list headed Not Vectorized Constructs.
The difference is the heap. The documentation lists relations among the kinds of object that can block full pushdown, and it never shows a heap-to-Iceberg join plan. What follows is my inference: DuckDB lives in another process and has no POSTGRES_SCAN in any plan the docs show, so a heap join executes in PostgreSQL over a foreign scan of the Iceberg side. If that’s right, then on pg_lake every heap join is the fallback case.
You create Iceberg tables with CREATE TABLE ... USING iceberg. What you get is a foreign table; \d shows it on a server named pg_lake_iceberg. The documentation says why that matters, in a remark about declarative partitioning: the PostgreSQL planner does not always know how to aggregate efficiently across foreign partitions.
Writes are where pg_lake pulls away from the others. INSERT, UPDATE, DELETE, and COPY all work on Iceberg tables, inside ordinary transactions that can also touch heap tables, and the documentation promises PostgreSQL’s ACID properties across both. pg_lake is its own Iceberg catalog, and the catalog update commits with the transaction. The limits are reasonable ones: no MERGE, no INSERT ... ON CONFLICT, no SELECT ... FOR UPDATE, no indexes, and an UPDATE or DELETE locks the table so that only one runs at a time. Tables in an external REST catalog are writable only if pg_lake created them; anything else attaches with read_only = true.
ColdFront: DuckDB in your backend
ColdFront is labeled beta, with a banner telling you not to run it in production, which you should believe. It has an all-Iceberg mode, but the reason to look at it is tiering: a partitioned table keeps recent partitions in the heap, an archiver (a Go binary run from cron) moves older partitions into Iceberg, and the application sees one relation. I’m describing the development documentation here; it is ahead of the released v1.0.0-beta2 in at least one way that matters, below.
The stack is stock PostgreSQL with pg_duckdb and the coldfront extension, a Lakekeeper REST catalog (which needs its own PostgreSQL database, so yes, there is a Postgres behind your Postgres), and an object store. DuckDB runs in-process, one instance per backend. The architecture page keeps a list of upstream problems it works around, and one is concurrent backends reading and deleting each other’s DuckDB spill files in a shared temporary directory.
The “one relation” is a view. The archiver renames events to _events and creates a view named events that is a UNION ALL of the heap table above a watermark and iceberg_scan('ice.public.events') below it. pg_duckdb decides whether to take a query by looking at the parse tree, and a reference to iceberg_scan is enough. Once it takes the query, it takes all of it. The docs call this all-or-nothing plan takeover and say plainly that there is no cost-based split between hot and cold. DuckDB runs the statement and reads the heap side through postgres_scan, with PostgreSQL applying partition pruning underneath.
This is Aurora’s full-pushdown path with no fallback underneath it, and with the takeover triggered by the view, not by the query. A query that only needs last week’s rows still goes through DuckDB; the documentation’s advice is to query _events directly when you know the query is hot-only. (So much for one relation.) There are odder corners. jsonb columns come back as json through the view, since DuckDB has no jsonb. Setting plan_cache_mode = force_generic_plan makes some parameterized reads fail outright. All of this is documented, to pgEdge’s credit, at a length most projects don’t manage for their features.
Cold-tier pruning depends on which documentation you read. In the development docs, the archiver partitions the Iceberg table by month(ts) or day(ts), so a predicate on the time column skips whole manifests. In beta2, the cold table has no partition spec at all (duckdb-iceberg rejected writes to partitioned tables), and pruning rests on per-file min/max statistics alone.
Writes work, by rewriting. A post_parse_analyze_hook intercepts DML on the view. An INSERT is split row by row against the watermark. An UPDATE or DELETE is classified by its WHERE clause: provably hot becomes ordinary DML on _events, provably cold becomes a duckdb.raw_query() call against Iceberg, and ambiguous depends on coldfront.allow_mixed_writes. When that’s on (the default), the statement writes both tiers in one transaction, which rolls back cleanly but is, per the docs, not crash-safe: a backend crash between the Iceberg upload and the PostgreSQL commit can orphan files in object storage. When it’s off, the statement is rejected. Any write that touches the cold tier refuses RETURNING, and an ambiguous UPDATE reports its command tag as SELECT n, counting hot rows only. Your ORM will have opinions about that.
pg_clickhouse: not reading Iceberg at all
pg_clickhouse turns up in these comparisons, and it doesn’t belong in the same row. It is a foreign data wrapper for ClickHouse. Its reference documentation does not contain the word “Iceberg.”
ClickHouse reads Iceberg: there is an iceberg() table function, an Iceberg table engine, and a DataLakeCatalog database engine that mounts a Glue, Unity, or REST catalog as a database. So you should be able to stand up ClickHouse, point it at your catalog, point pg_clickhouse at ClickHouse, and query Iceberg from psql. No documentation I found describes that combination; the surest route is probably clickhouse_query(), which passes arbitrary ClickHouse SQL through. Either way it is two hops and a second database server, with its own SQL dialect and its own bill.
What pg_clickhouse does, it does through the FDW API, and the pushdown is serious: WHERE clauses, aggregates, window functions, and joins between tables on the same ClickHouse server all travel as remote SQL, visible in EXPLAIN (VERBOSE). ClickHouse’s managed Postgres documentation says 14 of the 22 TPC-H queries push down fully. (The README’s roadmap still implies 12. The docs have not caught up with each other.)
The heap join is the standard FDW story, and the documentation shows it without flinching. Join a ClickHouse table to a local table, and the remote SQL is a bare SELECT duration, node_id FROM "default".logs; every row comes back to PostgreSQL, which hashes and joins them itself. The recommended fix is to rewrite the query so the aggregation happens remotely in a CTE and the join happens afterward. That works. It is also you doing the planner’s job.
Writes are INSERT and COPY FROM into ClickHouse tables; UPDATE and DELETE are on the roadmap. ClickHouse’s own documentation is clear that there is no shared transaction between the two systems.
What the planner knows
When DuckDB has the whole statement, it plans the lake side from real metadata. Aurora says so directly: PostgreSQL planner statistics have no effect on these foreign tables, and the engine plans from Parquet and Iceberg metadata. The guide’s TPC-H plans show what that buys, including Bloom filters built on one side of a join and pushed into the Parquet scan on the other. (What DuckDB knows about the heap side, through POSTGRES_SCAN, none of these projects’ docs say.)
The fallback is where it goes wrong, because there PostgreSQL has to place a lake scan in a join tree, and PostgreSQL knows nothing.
pg_lake’s documentation contains the best exhibit. In its partial-pushdown example, the PostgreSQL side reads Foreign Scan on public.inventory (cost=100.00..127.50 rows=333 width=0). That 333 is after a local filter, and it is what a 1,000-row guess times PostgreSQL’s default one-third selectivity for an inequality produces (the 1,000 is my inference). A few lines down, in the same plan, DuckDB’s estimate for the scan feeding it is Estimated Cardinality: 133110000. The full-pushdown example reports cost=0.00..0.00 rows=0, which is at least honest about not trying.
pg_clickhouse’s examples show rows=1000 on a foreign scan that returns 8 rows and rows=1000 on one that returns 1,000. The reference documents no statistics options at all.
Aurora’s guide never shows what PostgreSQL believes about a Custom Scan in the fallback case; the plans I read all run with COSTS OFF. It also never mentions running ANALYZE on a foreign table. A planner working from a default guess will cheerfully put a 133-million-row scan on the inner side of a nested loop. ColdFront avoids the problem by never letting PostgreSQL plan a cross-tier query in the first place.
Which one
If you’re on Aurora and want to read a lake someone else writes
Use aurora_analytics. There is no charge beyond the compute and the S3 requests, and there is nothing to operate. Before a heap join goes to production, run EXPLAIN (VERBOSE) and read the bottom. If you see Unsupported Pushdown Expressions, fix the type or the collation; you want the single Custom Scan.
If you want PostgreSQL to own the Iceberg tables
pg_lake. It is the only one of the four that promises ACID across heap and Iceberg tables in one transaction. The cost is a second process to supervise and size, and (if my reading is right) heap joins that PostgreSQL plans with no statistics.
If you want cold partitions to leave the heap without the application noticing
ColdFront is the only one that automates it into Iceberg. (pg_clickhouse documents the manual version, with a foreign partition pointing at ClickHouse.) It is also beta, the application will notice (RETURNING, command tags, jsonb), and every query through the view is planned by DuckDB. Test it. Don’t deploy it yet.
If you already run ClickHouse
pg_clickhouse is a good FDW for a database you already have. If you don’t already run ClickHouse, wanting to read Iceberg from PostgreSQL is not a reason to start.
Whichever you pick, the first thing to run is EXPLAIN (VERBOSE) on a join between a heap table and a lake table. One node at the top means DuckDB planned it. PostgreSQL nodes on top mean PostgreSQL did, with whatever it guessed.