AWS Builder Center
Aurora PostgreSQL Can Now Query Apache Iceberg Directly - I Put It to the Test

Aurora PostgreSQL Can Now Query Apache Iceberg Directly - I Put It to the Test

AWS just shipped something that quietly removes a whole category of ETL pipelines: Aurora PostgreSQL (17.11+ and 18.6+) can now query Apache Iceberg and Parquet files sitting in S3 directly, in the same SQL statement as your live operational tables. No export job, no Athena hop, no application-side join.

I didn't want a toy demo, so I built the whole thing: a real S3 + Glue Iceberg table holding five years of synthetic transaction history, and a real Aurora PostgreSQL 17.11 Serverless v2 cluster, wired together with the new aurora_analytics extension. Here's what that actually involved - every command, every error, and the query that was the point of all of it.
Stack: Aurora PostgreSQL 17.11 (Serverless v2, 0.5–2 ACU) · aurora_analytics extension · Apache Iceberg on S3 + Glue Data Catalog · us-east-1

Setting up the lake

An encrypted S3 bucket, a Glue database, and an Iceberg table created and loaded through Athena - the same path most teams already use for lake storage.
1
2
3
4
5
6
7
8
9
aws s3api create-bucket --bucket iceberg-demo-<account>-use1 --region us-east-1
aws s3api put-bucket-encryption --bucket iceberg-demo-<account>-use1 \
--server-side-encryption-configuration '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
aws s3api put-public-access-block --bucket iceberg-demo-<account>-use1 \
--public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

aws glue create-database --database-input '{"Name":"iceberg_demo"}'
aws athena create-work-group --name iceberg-demo-wg \
--configuration "ResultConfiguration={OutputLocation=s3://iceberg-demo-<account>-use1/athena-results/},EnforceWorkGroupConfiguration=true"
The table itself, created through Athena:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
CREATE TABLE transaction_history (
transaction_id string,
customer_id string,
merchant string,
category string,
amount decimal(10,2),
transaction_date date
)
LOCATION 's3://iceberg-demo-<account>-use1/iceberg/transaction_history/'
TBLPROPERTIES (
'table_type' = 'ICEBERG',
'format' = 'parquet',
'write_compression' = 'snappy'
)
Twenty-five rows went in across 2021–2025 via a plain INSERT INTO … VALUES. Athena handled the Iceberg snapshot, manifest list, and manifest file bookkeeping automatically - no Spark job required. Worth a quick look at what that actually wrote to S3, since it's what the extension reads later:
1
2
3
4
5
iceberg/transaction_history/data/.../....parquet 1.6 KiB # the actual data
iceberg/transaction_history/metadata/00000-....metadata.json 1.6 KiB # table created
iceberg/transaction_history/metadata/00001-....metadata.json 2.8 KiB # after INSERT - current snapshot
iceberg/transaction_history/metadata/....m0.avro 6.9 KiB # manifest file
iceberg/transaction_history/metadata/snap-....avro 4.2 KiB # manifest list
A versioned chain of metadata.json files, each pointing at a manifest list, which points at manifest files, which point at the Parquet data. Glue's table record stores a pointer to whichever metadata.json is current - that pointer matters a lot in Gotcha #2 below.

Provisioning Aurora, and the IAM role that's new here

This is the part that's genuinely new: a cluster-level IAM role attached under a specific feature name, AuroraAnalytics - not the familiar s3Import/s3Export pattern. The role trusts rds.amazonaws.com and needs read access to both the S3 bucket and the Glue Data Catalog.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
aws rds describe-db-engine-versions --engine aurora-postgresql \
--query "DBEngineVersions[?EngineVersion>='17.11'].EngineVersion"
# 17.4 17.5 17.6 17.7 17.9 17.11 18.3 18.4 18.6

aws rds create-db-cluster \
--db-cluster-identifier iceberg-demo-cluster \
--engine aurora-postgresql --engine-version 17.11 \
--master-username pgadmin --master-user-password '<pw>' \
--db-subnet-group-name iceberg-demo-subnet-group \
--vpc-security-group-ids <sg> \
--serverless-v2-scaling-configuration MinCapacity=0.5,MaxCapacity=2 \
--enable-http-endpoint \
--storage-encrypted \
--database-name icebergdemo

aws rds create-db-instance \
--db-instance-identifier iceberg-demo-instance-1 \
--db-cluster-identifier iceberg-demo-cluster \
--engine aurora-postgresql --db-instance-class db.serverless \
--publicly-accessible
The IAM role itself - trust policy plus a scoped S3 + Glue read policy:
1
2
3
4
5
6
7
8
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "rds.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "S3DataLakeAccess",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::iceberg-demo-<account>-use1",
"arn:aws:s3:::iceberg-demo-<account>-use1/*"
]
},
{
"Sid": "GlueCatalogAccess",
"Effect": "Allow",
"Action": ["glue:GetDatabase", "glue:GetDatabases", "glue:GetTable", "glue:GetTables", "glue:GetPartition", "glue:GetPartitions"],
"Resource": [
"arn:aws:glue:us-east-1:<account>:catalog",
"arn:aws:glue:us-east-1:<account>:database/iceberg_demo",
"arn:aws:glue:us-east-1:<account>:table/iceberg_demo/*"
]
}
]
}
1
2
3
4
5
6
7
8
9
aws iam create-role --role-name aurora-analytics-iceberg-demo-role \
--assume-role-policy-document file://trust-policy.json
aws iam put-role-policy --role-name aurora-analytics-iceberg-demo-role \
--policy-name AuroraAnalyticsS3GlueAccess --policy-document file://analytics-policy.json

aws rds add-role-to-db-cluster \
--db-cluster-identifier iceberg-demo-cluster \
--role-arn arn:aws:iam::<account>:role/aurora-analytics-iceberg-demo-role \
--feature-name AuroraAnalytics
One timing detail: the role attachment only succeeds once the cluster status is available - doing it during creating throws InvalidDBClusterStateFault. After a successful attach, describe-db-clusters shows the role's status as PENDING for roughly a minute before flipping to ACTIVE; nothing downstream works until it does.

Gotcha #1: CREATE EXTENSION isn't the same as "on"

CREATE EXTENSION aurora_analytics succeeds immediately and registers the foreign server and FDW. Try to use it, though, and you get:
1
2
ERROR: aurora_analytics is not enabled
Hint: Set aurora_analytics.enabled to true in the parameter group and retry the query.
The parameter lives in a cluster parameter group, defaults off, and its apply method is pending-reboot - meaning a parameter group swap alone doesn't take effect.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
aws rds create-db-cluster-parameter-group \
--db-cluster-parameter-group-name iceberg-demo-pg17 \
--db-parameter-group-family aurora-postgresql17

aws rds modify-db-cluster-parameter-group \
--db-cluster-parameter-group-name iceberg-demo-pg17 \
--parameters "ParameterName=aurora_analytics.enabled,ParameterValue=1,ApplyMethod=pending-reboot"

aws rds modify-db-cluster \
--db-cluster-identifier iceberg-demo-cluster \
--db-cluster-parameter-group-name iceberg-demo-pg17 \
--apply-immediately

aws rds reboot-db-instance --db-instance-identifier iceberg-demo-instance-1
aws rds wait db-instance-available --db-instance-identifier iceberg-demo-instance-1
The full parameter family, for reference - describe-db-cluster-parameters on the new group surfaces seven aurora_analytics.* knobs, every one of them pending-reboot:
ParameterControls
aurora_analytics.enabledmaster on/off switch
aurora_analytics.enable_loggingwhether the extension writes its own logs
aurora_analytics.logging_levelDEBUG5 … ERROR
aurora_analytics.query_memper-query memory ceiling (KB), scales with instance class
aurora_analytics.track_detailed_metricsfeeds the per-query stats in aurora_analytics_stat_statements()
aurora_analytics.lowercase_unquoted_column_namecase handling on inferred foreign-table columns
aurora_analytics.skip_unsupported_columnsskip vs. error on unmappable columns
Budget a couple of minutes for the reboot before anything downstream works - on Serverless v2 this one took under two minutes.

Gotcha #2: Athena's Iceberg tables don't write what the reader expects

The bulk path the docs lead with - IMPORT FOREIGN SCHEMA straight from the Glue database - threw a generic, unhelpful error for me:
1
2
IMPORT FOREIGN SCHEMA iceberg_demo FROM SERVER aurora_analytics_server INTO datalake;
-- ERROR: Database returned SQL Exception
A direct CREATE FOREIGN TABLE against the same table failed too, but with a far more useful error:
1
2
3
4
5
6
7
8
9
CREATE FOREIGN TABLE datalake.transaction_history ()
SERVER aurora_analytics_server
OPTIONS (location 's3://.../iceberg/transaction_history/', format 'iceberg');

-- ERROR: [Aurora Analytics] failed to fetch column information due to error:
-- 'Invalid Error: Invalid Configuration Error: The specified Iceberg table
-- could not be read because the associated metadata file could not be found.
-- Version-hint.text may be missing. Please provide direct metadata path in
-- location option: s3://bucket/table/metadata/<version>.metadata.json'
The embedded Iceberg reader (DuckDB-based) expects a Hive/Spark-style version-hint.text pointer at the table root. Athena-managed Iceberg tables don't write one - Athena tracks the current snapshot entirely inside Glue's own metadata_location table property instead:
1
2
3
4
5
aws glue get-table --database-name iceberg_demo --name transaction_history \
--query 'Table.Parameters'
# "metadata_location": "s3://.../metadata/00001-....metadata.json",
# "previous_metadata_location": "s3://.../metadata/00000-....metadata.json",
# "table_type": "ICEBERG"
Pointing the foreign table straight at that exact file fixed it immediately, with every column correctly inferred - no manual column list needed:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
CREATE FOREIGN TABLE datalake.transaction_history ()
SERVER aurora_analytics_server
OPTIONS (
location 's3://.../iceberg/transaction_history/metadata/00001-....metadata.json',
format 'iceberg'
);
-- CREATE FOREIGN TABLE · all six columns inferred automatically

SELECT * FROM datalake.transaction_history LIMIT 5;
-- T-00001 C-1001 Whole Foods Groceries 84.23 2021-03-14
-- T-00002 C-1001 Shell Gas Fuel 52.10 2021-07-02
-- T-00003 C-1001 Amazon Shopping 129.99 2021-11-19
-- T-00004 C-1001 Delta Airlines Travel 412.50 2022-01-08
-- T-00005 C-1001 Starbucks Dining 6.75 2022-02-23
For a bulk, Glue-driven import across many tables, the fix scales: pull each table's live metadata_location with glue:GetTable and generate the CREATE FOREIGN TABLE statements from that - a few lines of script, not a blocker.

The query that was the point of all this

A customer's last 30 days, still warm in a native Aurora table, unioned with everything the lake has on them going back five years. One plan, one round trip, no application-side merge:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CREATE TABLE recent_transactions (
transaction_id text PRIMARY KEY,
customer_id text,
merchant text,
category text,
amount numeric(10,2),
transaction_date date
);
-- 7 rows inserted, dated within the last 30 days

SELECT merchant, category, amount, transaction_date, 'recent' AS source
FROM recent_transactions
WHERE customer_id = 'C-1001'
UNION ALL
SELECT merchant, category, amount, transaction_date, 'historical' AS source
FROM datalake.transaction_history
WHERE customer_id = 'C-1001'
AND transaction_date >= CURRENT_DATE - INTERVAL '5 years'
ORDER BY transaction_date DESC
LIMIT 15;
Full result, unedited:
MerchantCategoryAmountDateSource
Whole FoodsGroceries$92.102026-09-30recent
Shell GasFuel$61.202026-09-27recent
Apple StoreElectronics$1,099.002026-09-23recent
ChipotleDining$16.752026-09-20recent
Delta AirlinesTravel$528.402026-09-14recent
Best BuyElectronics$249.992024-09-12historical
Delta AirlinesTravel$455.202024-07-30historical
Whole FoodsGroceries$88.602024-05-27historical
NetflixEntertainment$15.492024-03-15historical
Shell GasFuel$58.902024-02-02historical
CostcoGroceries$210.552023-12-18historical
ChipotleDining$14.302023-10-09historical
Apple StoreElectronics$999.002023-08-21historical
Whole FoodsGroceries$76.882023-06-02historical
MarriottTravel$389.002023-04-19historical
Rows written to Aurora within the last two weeks, interleaved by date with rows that have lived in S3 as Parquet since 2023 - sorted together, filtered together, in one ORDER BY. I ran this through the AWS CLI / RDS Data API, and separately confirmed it over a direct psql connection too - both returned the identical fifteen rows.

A couple of extras worth knowing

Materializing a hot slice. For data queried often enough that even the pruned S3 scan is overhead, CREATE TABLE … AS SELECT against the foreign table pulls it into native Aurora storage:
1
2
3
4
5
6
7
8
CREATE TABLE transaction_history_2024 AS
SELECT * FROM datalake.transaction_history
WHERE transaction_date >= DATE '2024-01-01';
-- SELECT 8

SELECT count(*), min(transaction_date), max(transaction_date)
FROM transaction_history_2024;
-- 8 | 2024-02-02 | 2025-01-19
Watching what it actually scans. aurora_analytics_stat_statements() extends the familiar pg_stat_statements pattern with per-query timing, keyed to the same normalized query text:
1
2
3
4
SELECT query, calls, total_exec_time, rows
FROM aurora_analytics_stat_statements()
WHERE query ILIKE '%transaction_history%'
ORDER BY calls DESC;
QueryCallsTotal exec timeRows
CREATE FOREIGN TABLE transaction_history (…)178.0 ms0
SELECT * FROM datalake.transaction_history LIMIT $11137.8 ms5
SELECT … UNION ALL … (the headline query)117.4 ms15
The union query was faster than the raw table scan it was built from - by the time it ran, the Iceberg manifest read from the prior query was already cached. That's the "query caching" line in the release notes, made concrete: the second touch of the same files didn't pay the S3 round trip again.

What it costs

Nothing here is free-tier:
  • Aurora Serverless v2, 0.5 ACU floor - this workload never pushed past it
  • S3 - storage plus GET requests per scan; Iceberg's pruning keeps per-query cost low (0.83 KB scanned for the query above, confirmed via Athena on the same table)
  • Secrets Manager - one secret, used so the RDS Data API could run SQL over HTTPS
  • No separate charge for aurora_analytics itself - it bills as ordinary Aurora compute plus standard S3 request pricing, exactly as documented

The headline here isn't really the SQL - UNION ALL across a foreign table is not new syntax. It's that Aurora now treats your data lake as just another table, with real predicate pushdown and caching, instead of a separate system you query separately and reconcile in application code. For teams sitting on years of "cold" operational history in S3, that's a meaningfully smaller architecture.
Built end-to-end with the AWS CLI, the RDS Data API, and the Athena and Glue consoles - Aurora PostgreSQL 17.11, us-east-1.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article