Iceberg views: a versioned view definition, and the cross-engine promise tested
An Iceberg view is a metadata file with versions, schemas and one SQL representation per dialect - a view that is tracked the way a table is. Here is what that file contains, what a replace actually does to it, and what happened when a view written by Spark was read from Trino.
- What a Hive view could not do
- Creating one, and what lands where
- What is in the metadata file
- Replacing a view keeps the old one
- The cross-engine test
- Why it failed, precisely
- So when are they worth using?
- Views and materialized views
- Run it yourself
- Common misconceptions
- Frequently asked questions
- Conclusion
- References
- Trademarks
TL;DR
- An Iceberg view is not SQL text in a metastore column. It is a metadata JSON file with
schemas,versions, aversion-logand one SQL representation per dialect — the same treatment a table gets.CREATE OR REPLACE VIEWdoes not overwrite. Two replaces produced three versions and two schemas in the metadata. The history is bounded, though: after fifteen versions the current metadata listed only the last ten (version.history.num-entries).- The engine marks its work: Spark wrote
table_type = ICEBERG-VIEWinto the Hive Metastore alongside ametadata_location. That marker is how an engine tells an Iceberg view from a Hive view.- The cross-engine promise did not hold in this setup, in either direction. Trino listed the Spark-created view as
VIEWand then failed to query it withNot an Iceberg table. Spark’s Iceberg catalog could not see the view Trino created at all.- The reason is visible in the metastore: over a Hive Metastore, Trino writes neither an Iceberg view nor a plain Hive view but a Trino view, its definition base64-encoded in the view text. And Spark stored one representation,
dialect: "spark", which only an engine that runs Spark SQL can use.- Views are a catalog capability, not a format feature you get everywhere. What works depends on the catalog and the engine’s support for it, which is worth testing before designing around it.
A view is the oldest abstraction in analytics: name a query, let people use the name. What Iceberg adds is that the definition stops being an untracked string in a metastore and becomes a versioned object with a schema, exactly like a table.
Whether that buys you the thing it is usually sold for — one view definition, many engines — is a separate question, and one worth answering by running it.
What a Hive view could not do
A Hive view is a row in the metastore holding SQL text. That has four consequences that show up in practice:
- No versioning. Replacing it destroys the previous definition. There is no history, no way to see what it used to say, and no way back.
- No tracked schema. The columns are whatever the text happens to produce today. A change upstream silently changes the view’s shape.
- One dialect. The text is written in whichever engine’s SQL created it. Another engine parses it and hopes.
- No identity. Nothing distinguishes a view definition from any other metastore entry beyond its type.
Iceberg’s view spec answers the first three directly, and the fourth turns out to matter more than it sounds.
Creating one, and what lands where
The syntax is unremarkable. On an orders table in the stack’s Hive-backed ice
catalog:
CREATE TABLE ice.blog_views.orders (
order_id BIGINT, city STRING, amount DOUBLE, discount DOUBLE) USING iceberg;
INSERT INTO ice.blog_views.orders VALUES
(1001, 'paris', 120.00, 5.0), (1002, 'lyon', 35.50, 0.0),
(1003, 'paris', 64.90, 2.5), (1004, 'nice', 210.00, 10.0),
(1005, 'lyon', 18.00, 0.0), (1006, 'nice', 99.99, 4.0);
CREATE OR REPLACE VIEW ice.blog_views.big_orders AS
SELECT city, amount FROM ice.blog_views.orders WHERE amount > 50;
What lands in the Hive Metastore is not. Reading the metastore’s own Postgres tables for that view:
docker exec postgres psql -U postgres -d metastore_db -Atc "
SELECT t.\"TBL_TYPE\", p.\"PARAM_KEY\", left(p.\"PARAM_VALUE\", 70)
FROM \"TBLS\" t JOIN \"DBS\" d ON t.\"DB_ID\" = d.\"DB_ID\"
JOIN \"TABLE_PARAMS\" p ON p.\"TBL_ID\" = t.\"TBL_ID\"
WHERE d.\"NAME\" = 'blog_views' AND t.\"TBL_NAME\" = 'big_orders' ORDER BY 2"
VIRTUAL_VIEW|create_engine_version|Spark 3.5.9
VIRTUAL_VIEW|current-schema|{"type":"struct","schema-id":1,"fields":[{"id":0,"name":"city","requir
VIRTUAL_VIEW|engine_version|Spark 3.5.9
VIRTUAL_VIEW|metadata_location|s3a://warehouse/blog_views.db/big_orders/metadata/00014-3f3da472-fc1f-
VIRTUAL_VIEW|previous_metadata_location|s3a://warehouse/blog_views.db/big_orders/metadata/00013-54c9aab5-a8e0-
VIRTUAL_VIEW|spark.query-column-names|city
VIRTUAL_VIEW|table_type|ICEBERG-VIEW
VIRTUAL_VIEW|transient_lastDdlTime|1790743976
VIRTUAL_VIEW|uuid|b546f62c-a8c2-4a74-933e-8ef0753f7259
That listing was taken after all the replaces below, so it points at metadata
file 00014. Two things to notice. The metastore entry is a pointer,
exactly as it is for an Iceberg table: the real definition lives in a metadata
file on object storage, and previous_metadata_location points at the one
before it. And the marker is ICEBERG-VIEW, not ICEBERG. An engine reading the
metastore uses that string to decide what it is looking at, which becomes the
whole story further down.
The metastore also keeps VIEW_ORIGINAL_TEXT and VIEW_EXPANDED_TEXT populated
with the current SQL, SELECT city FROM ice.blog_views.orders WHERE amount > 112
at that point, for the benefit of tools that only know how to read a Hive view.
That copy is not decoration: Spark’s own Hive session catalog read this Iceberg
view through it, as the cross-engine table below shows.
What is in the metadata file
This is the part worth reading in full, because the structure is the feature.
The first metadata file, written by the CREATE, parsed from MinIO:
{
"view-uuid": "b546f62c-a8c2-4a74-933e-8ef0753f7259",
"format-version": 1,
"location": "s3a://warehouse/blog_views.db/big_orders",
"schemas": [
{ "type": "struct", "schema-id": 0, "fields": [
{ "id": 0, "name": "city", "required": false, "type": "string" },
{ "id": 1, "name": "amount", "required": false, "type": "double" } ] }
],
"current-version-id": 1,
"versions": [
{
"version-id": 1,
"timestamp-ms": 1790743976015,
"schema-id": 0,
"summary": {
"engine-version": "3.5.9",
"app-id": "local-1790743966001",
"engine-name": "spark",
"iceberg-version": "Apache Iceberg 1.11.0 (commit 6976e020b894f6a6777704df2b8c4458cb291ae9)",
"app-name": "iceberg-views"
},
"default-catalog": "spark_catalog",
"default-namespace": [ "default" ],
"representations": [
{ "type": "sql",
"sql": "SELECT city, amount FROM ice.blog_views.orders WHERE amount > 50",
"dialect": "spark" }
]
}
],
"version-log": [ { "timestamp-ms": 1790743976015, "version-id": 1 } ]
}
The file is named 00000-23e72d4b...gz.metadata.json: view metadata is
gzip-compressed by default (write.metadata.compression-codec = gzip in
ViewProperties), so read it with gunzip, not cat.
Five things are doing work here:
schemaswith field ids, the same mechanism that makes column renames free in a table. A view’s shape is recorded, not inferred.versions, each a complete definition. Replacing adds one rather than overwriting.representations, a list — and each carries adialect. This is the cross-engine mechanism: one view can hold the same query written for several engines.default-cataloganddefault-namespace, captured at creation, so that unqualified names in the SQL resolve the way they did when it was written rather than the way the current session happens to be configured.summary, naming the engine, Iceberg version and even the Spark application that produced it — useful when a definition behaves differently than expected.
Replacing a view keeps the old one
CREATE OR REPLACE VIEW reads as destructive and is not. Running it twice more,
the second time also changing the column list:
CREATE OR REPLACE VIEW ice.blog_views.big_orders AS
SELECT city, amount FROM ice.blog_views.orders WHERE amount > 100;
CREATE OR REPLACE VIEW ice.blog_views.big_orders AS
SELECT city FROM ice.blog_views.orders WHERE amount > 100;
The metadata afterwards:
current-version-id: 3
versions: 3 schemas: 2
version-log: [1, 2, 3]
v1 schema-id=0 dialect=spark: SELECT city, amount FROM ice.blog_views.orders WHERE amount > 50
v2 schema-id=0 dialect=spark: SELECT city, amount FROM ice.blog_views.orders WHERE amount > 100
v3 schema-id=1 dialect=spark: SELECT city FROM ice.blog_views.orders WHERE amount > 100
Every definition is still there. A second schema appeared only when the column
list actually changed: versions 1 and 2 differ in their predicate and share
schema-id 0, while version 3 dropped a column and got schema-id 1. Each
replace also wrote a new metadata file, three in all.
So a view carries the same kind of audit trail a table does: what the definition was, when it changed, which engine changed it, and what shape it had at the time. That alone is a real improvement on a metastore string, regardless of how the cross-engine story turns out.
The history keeps ten versions
The audit trail has a length. Twelve more replaces, each with a different threshold, took the view to version 15:
current-version-id: 15 versions kept: [15, 14, 13, 12, 11, 10, 9, 8, 7, 6] version-log entries: 10 schemas: 2
The current metadata lists the last ten versions and ten version-log
entries; versions 1 to 5 are gone from it. The limit is the view property
version.history.num-entries, default 10 in ViewProperties. Nothing was
deleted from storage, though: all fifteen metadata files were still in MinIO,
and the oldest, 00000-..., still held version 1’s
SELECT city, amount FROM ice.blog_views.orders WHERE amount > 50. If you rely on
the history, raise the property or keep the old metadata files; the current file
alone does not remember everything.
The cross-engine test
The headline claim for Iceberg views is that one definition serves many engines. The stack here has Spark and Trino 483 over the same Hive Metastore and the same storage, which is the setup that claim describes. So:
-- in Trino
SHOW TABLES FROM iceberg.blog_views;
"big_orders"
"discounts"
"orders"
Trino sees it (discounts is a second view the scripts below create). It even
classifies it correctly:
SELECT table_name, table_type FROM iceberg.information_schema.tables
WHERE table_schema = 'blog_views' ORDER BY 1;
"big_orders","VIEW"
"discounts","VIEW"
"orders","BASE TABLE"
And then:
SELECT * FROM iceberg.blog_views.big_orders;
Query ... failed: Not an Iceberg table: blog_views.big_orders
SHOW CREATE VIEW fails the same way, while SELECT count(*) on the orders
table itself returns 6. The other direction is no better. A view created by
Trino:
-- in Trino: succeeds, and Trino can query it
CREATE OR REPLACE VIEW iceberg.blog_views.trino_big_orders AS
SELECT city, amount FROM iceberg.blog_views.orders WHERE amount > 50;
SELECT city, amount FROM iceberg.blog_views.trino_big_orders ORDER BY amount;
"paris","64.9"
"nice","99.99"
"paris","120.0"
"nice","210.0"
From Spark, through Iceberg’s catalog and through Spark’s own Hive session catalog on the same metastore:
views Iceberg's catalog lists: ['big_orders', 'discounts']
ice.blog_views.trino_big_orders (Iceberg catalog): AnalysisException: [TABLE_OR_VIEW_NOT_FOUND] The table or view `ice`.`blog_views`.`trino_big_orders` cannot be found. ...
spark_catalog.blog_views.trino_big_orders (Hive session catalog): AnalysisException: [INVALID_VIEW_TEXT] The view `spark_catalog`.`blog_views`.`trino_big_orders` cannot be displayed due to invalid view text: /* Presto View */. ...
spark_catalog.blog_views.big_orders (Spark's Iceberg view via Hive): 2 rows
Trino also has a plain hive catalog on the same metastore, which gives four
readers in all:
| View created by | Stored as | Spark, ice (Iceberg) |
Spark, Hive session catalog | Trino, iceberg |
Trino, hive |
|---|---|---|---|---|---|
| Spark | Iceberg view (table_type = ICEBERG-VIEW) |
yes | yes, via the SQL copy | no: Not an Iceberg table |
no: Hive views are not supported |
| Trino | Trino view (presto_view = true) |
no: not found | no: INVALID_VIEW_TEXT |
yes | yes |
Neither engine’s own Iceberg catalog could read the other’s view. The only crossing that worked was Spark reading its own Iceberg view as a Hive view, which is Spark talking to Spark.
Why it failed, precisely
Two distinct causes, and it is worth separating them because they have different answers.
Trino did not create an Iceberg view. Checking what Trino wrote into the
metastore for trino_big_orders:
VIRTUAL_VIEW|comment|Presto View
VIRTUAL_VIEW|presto_view|true
VIRTUAL_VIEW|transient_lastDdlTime|1790744012
VIRTUAL_VIEW|trino_created_by|Trino Iceberg connector
VIRTUAL_VIEW|trino_query_id|20260929_191227_00025_akf4b
VIRTUAL_VIEW|trino_version|483
No table_type, no metadata_location, and nothing written to MinIO. The view
text is /* Presto View: eyJvcmlnaW5hbFNxbCI6... */, a base64-encoded JSON
definition, with /* Presto View */ as the expanded text. That is a Trino
view: neither an Iceberg view nor a Hive view that Hive or Spark could run.
The stack’s Trino connector is configured with iceberg.catalog.type=hive_metastore.
In that configuration Trino 483’s TrinoHiveCatalog creates and reads views
through TrinoViewHiveMetastore, which only knows the presto_view format. An
object marked ICEBERG-VIEW is not one, so Trino falls through to its table
loader, which rejects anything whose table_type is not ICEBERG with
UnknownTableTypeException: the Not an Iceberg table above. Going the other
way, Spark’s Iceberg catalog accepts only objects marked ICEBERG-VIEW
(HiveOperationsBase.validateTableIsIcebergView in Iceberg 1.11.0), so it does
not consider Trino’s view one of its objects at all. Neither behaviour is a bug;
it is what each engine’s Hive-metastore catalog implements.
Spark stored one dialect. Look again at the representation list:
"representations": [
{ "type": "sql", "sql": "...", "dialect": "spark" }
]
One entry, dialect: "spark", in every one of the fifteen versions. The spec’s
design is that a view holds several representations and an engine picks the one
it can parse. A view written by Spark has only Spark’s, so a reader that will not
execute Spark SQL has nothing it can use, even once it recognises the object.
Iceberg also protects the representations another engine added: with
replace.drop-dialect.allowed at its default of false, a replace that would drop
another dialect’s SQL is refused.
That is the honest shape of the feature. The format defines a multi-dialect container; filling it with more than one dialect is something an engine or a tool has to do, and neither engine does it for you.
So when are they worth using?
They are still worth using, but for the versioning rather than the portability — unless you have tested the portability on your own catalog.
- Single-engine, today. If Spark writes and Spark reads, an Iceberg view gives
you version history, a tracked schema and an audit trail that a Hive view does
not. That is a real gain with no cross-engine assumption; raise
version.history.num-entriesif ten versions is too short a memory. - Test your catalog before designing around portability. The result above is specific to a Hive Metastore catalog with these engine versions. A REST catalog is the configuration where cross-engine view support is actively developed, and it is the first thing to try if portability is the goal.
- Treat the dialect as part of the contract. If two engines must read one
view, something has to write a representation for each. Check what is in
representationsrather than assuming. - A shared table is a safer contract than a shared view. Both engines read the same Iceberg table in this stack without any of this trouble. If you need one definition consumed by several engines today, materialising it — a table refreshed by a job — moves the problem to ground where interoperability already works.
Views and materialized views
They solve different problems and are easy to conflate. A view stores a query and runs it on every read; it costs nothing to keep current because there is nothing to keep. A materialized view stores the result and therefore needs a refresh strategy and a staleness policy.
Iceberg’s view spec covers the former. Materialized views exist in some engines on top of Iceberg tables, with the refresh handled by the engine rather than the format, which is why their behaviour varies much more between engines than views do.
Run it yourself
Everything above was run on the datalake-playground stack with Spark 3.5.9, Iceberg 1.11.0 and Trino 483. Start it:
git clone https://github.com/rangareddy/datalake-playground.git
cd datalake-playground
SPARK_VERSION=3.5.9 sh run_datalake.sh start
sh run_datalake.sh status
The Trino half needs the all profile, which adds Trino to the stack: start with
PROFILE=all SPARK_VERSION=3.5.9 sh run_datalake.sh start instead.
Step 1, Spark: create, replace and inspect. Save as views_versions.py:
"""Create, replace and inspect an Iceberg view on the Hive catalog.
Run inside the datalake-playground stack:
docker cp views_versions.py spark-master:/tmp/
docker exec spark-master bash -c '$SPARK_HOME/bin/spark-submit --master "local[2]" \
--driver-memory 1g --jars $(ls $ICEBERG_HOME/iceberg-spark-runtime*.jar) \
/tmp/views_versions.py'
"""
import gzip
import json
from pyspark.sql import SparkSession
spark = (
SparkSession.builder.appName("iceberg-views")
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.config("spark.sql.catalog.ice", "org.apache.iceberg.spark.SparkCatalog")
.config("spark.sql.catalog.ice.type", "hive")
.config("spark.sql.catalog.ice.uri", "thrift://hive-metastore:9083")
.config("spark.sql.catalog.ice.warehouse", "s3a://warehouse/")
.config("spark.eventLog.enabled", "false")
.config("spark.ui.enabled", "false")
.getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
sql = spark.sql
NS = "ice.blog_views"
def view_metadata(view):
"""Every metadata file of a view, oldest first, parsed from gzip JSON."""
jvm = spark._jvm
conf = spark._jsc.hadoopConfiguration()
path = jvm.org.apache.hadoop.fs.Path(f"s3a://warehouse/blog_views.db/{view}/metadata")
fs = path.getFileSystem(conf)
files = sorted(s.getPath().toString() for s in fs.listStatus(path))
out = []
for f in files:
stream = fs.open(jvm.org.apache.hadoop.fs.Path(f))
raw = bytes(jvm.org.apache.commons.io.IOUtils.toByteArray(stream))
stream.close()
out.append((f.rsplit("/", 1)[1], json.loads(gzip.decompress(raw))))
return out
def summarise(meta):
print(f"current-version-id: {meta['current-version-id']}")
print(f"versions: {len(meta['versions'])} schemas: {len(meta['schemas'])}")
print(f"version-log: {[e['version-id'] for e in meta['version-log']]}")
for v in meta["versions"]:
reps = ", ".join(f"dialect={r['dialect']}: {r['sql']}" for r in v["representations"])
print(f" v{v['version-id']} schema-id={v['schema-id']} {reps}")
sql(f"CREATE NAMESPACE IF NOT EXISTS {NS}")
sql(f"DROP VIEW IF EXISTS {NS}.big_orders")
sql(f"DROP TABLE IF EXISTS {NS}.orders PURGE")
sql(f"""CREATE TABLE {NS}.orders (
order_id BIGINT, city STRING, amount DOUBLE, discount DOUBLE)
USING iceberg""")
sql(f"""INSERT INTO {NS}.orders VALUES
(1001, 'paris', 120.00, 5.0), (1002, 'lyon', 35.50, 0.0),
(1003, 'paris', 64.90, 2.5), (1004, 'nice', 210.00, 10.0),
(1005, 'lyon', 18.00, 0.0), (1006, 'nice', 99.99, 4.0)""")
# 1. create
sql(f"""CREATE OR REPLACE VIEW {NS}.big_orders AS
SELECT city, amount FROM {NS}.orders WHERE amount > 50""")
print("== 1. created ==")
print("rows through the view:", sql(f"SELECT count(*) FROM {NS}.big_orders").collect()[0][0])
files = view_metadata("big_orders")
print("metadata files:", [f for f, _ in files])
first = files[0][1]
print(json.dumps({k: first[k] for k in ("view-uuid", "format-version", "location", "schemas",
"current-version-id", "versions", "version-log")},
indent=2))
# 2. replace twice, the second time changing the column list
sql(f"""CREATE OR REPLACE VIEW {NS}.big_orders AS
SELECT city, amount FROM {NS}.orders WHERE amount > 100""")
sql(f"""CREATE OR REPLACE VIEW {NS}.big_orders AS
SELECT city FROM {NS}.orders WHERE amount > 100""")
print("\n== 2. after two replaces ==")
files = view_metadata("big_orders")
print("metadata files:", len(files))
summarise(files[-1][1])
print("rows through the view:", [r.city for r in sql(f"SELECT * FROM {NS}.big_orders ORDER BY city").collect()])
# 3. how many versions does a view keep?
for i in range(12):
sql(f"""CREATE OR REPLACE VIEW {NS}.big_orders AS
SELECT city FROM {NS}.orders WHERE amount > {101 + i}""")
latest = view_metadata("big_orders")[-1][1]
print("\n== 3. after 12 more replaces ==")
print(f"current-version-id: {latest['current-version-id']} "
f"versions kept: {[v['version-id'] for v in latest['versions']]} "
f"version-log entries: {len(latest['version-log'])} schemas: {len(latest['schemas'])}")
# 4. dropping a column the view uses
sql(f"""CREATE OR REPLACE VIEW {NS}.discounts AS
SELECT order_id, discount FROM {NS}.orders WHERE discount > 0""")
print("\n== 4. drop a column the view reads ==")
print("before:", sql(f"SELECT count(*) FROM {NS}.discounts").collect()[0][0], "rows")
sql(f"ALTER TABLE {NS}.orders DROP COLUMN discount") # last column: allowed on Hive
try:
sql(f"SELECT * FROM {NS}.discounts").collect()
print("after: view still reads")
except Exception as e:
print("after:", str(e).splitlines()[0][:160])
print("recorded view schema still:",
[f["name"] for f in view_metadata("discounts")[-1][1]["schemas"][-1]["fields"]])
print("\n== 5. what Spark lists ==")
sql(f"SHOW VIEWS IN {NS}").show(truncate=False)
spark.stop()
It prints the first metadata file, the three-version summary, the ten-version
window, the dropped-column failure and the view list shown above. The metastore
listing comes from the psql query in Creating one.
Step 2, Trino: read the Spark view and create one of its own.
docker exec trino trino --execute "SHOW TABLES FROM iceberg.blog_views"
docker exec trino trino --execute "SELECT table_name, table_type FROM iceberg.information_schema.tables WHERE table_schema = 'blog_views' ORDER BY 1"
docker exec trino trino --execute "SELECT * FROM iceberg.blog_views.big_orders"
docker exec trino trino --execute "CREATE OR REPLACE VIEW iceberg.blog_views.trino_big_orders AS SELECT city, amount FROM iceberg.blog_views.orders WHERE amount > 50"
docker exec trino trino --execute "SELECT city, amount FROM iceberg.blog_views.trino_big_orders ORDER BY amount"
docker exec trino trino --execute "SELECT * FROM hive.blog_views.big_orders"
docker exec trino trino --execute "SELECT count(*) FROM hive.blog_views.trino_big_orders"
Step 3, Spark: try to read Trino’s view. Save as views_from_trino.py:
"""Try to read, from Spark, a view that Trino created over the same Hive Metastore.
Run inside the datalake-playground stack, after the Trino CREATE VIEW:
docker cp views_from_trino.py spark-master:/tmp/
docker exec spark-master bash -c '$SPARK_HOME/bin/spark-submit --master "local[2]" \
--driver-memory 1g --jars $(ls $ICEBERG_HOME/iceberg-spark-runtime*.jar) \
/tmp/views_from_trino.py'
`ice` is Iceberg's catalog on the metastore; `spark_catalog` is Spark's own
Hive session catalog on the same metastore.
"""
from pyspark.sql import SparkSession
spark = (
SparkSession.builder.appName("iceberg-views-from-trino")
.config("spark.sql.extensions",
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
.config("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
.config("spark.sql.catalog.ice", "org.apache.iceberg.spark.SparkCatalog")
.config("spark.sql.catalog.ice.type", "hive")
.config("spark.sql.catalog.ice.uri", "thrift://hive-metastore:9083")
.config("spark.sql.catalog.ice.warehouse", "s3a://warehouse/")
.config("spark.sql.catalogImplementation", "hive")
.config("spark.hadoop.hive.metastore.uris", "thrift://hive-metastore:9083")
.config("spark.eventLog.enabled", "false")
.config("spark.ui.enabled", "false")
.enableHiveSupport()
.getOrCreate()
)
spark.sparkContext.setLogLevel("ERROR")
def attempt(label, query):
try:
rows = spark.sql(query).collect()
print(f"{label}: {len(rows)} rows")
except Exception as e:
print(f"{label}: {type(e).__name__}: {str(e).splitlines()[0][:150]}")
print("views Iceberg's catalog lists:",
[r.viewName for r in spark.sql("SHOW VIEWS IN ice.blog_views").collect()])
attempt("ice.blog_views.trino_big_orders (Iceberg catalog)",
"SELECT * FROM ice.blog_views.trino_big_orders")
attempt("spark_catalog.blog_views.trino_big_orders (Hive session catalog)",
"SELECT * FROM spark_catalog.blog_views.trino_big_orders")
attempt("spark_catalog.blog_views.big_orders (Spark's Iceberg view via Hive)",
"SELECT * FROM spark_catalog.blog_views.big_orders")
spark.stop()
Clean up. Drop Trino’s view from Trino and the rest from Spark, then remove
what the drops leave on MinIO. DROP VIEW deletes only a view’s current
metadata file, so fourteen of big_orders’ fifteen files stayed behind, and
PURGE leaves empty folder markers. Keep the mc rm last: run it before the
drops and Spark can no longer load the table to drop it.
docker exec trino trino --execute "DROP VIEW iceberg.blog_views.trino_big_orders"
docker exec spark-master bash -c '$SPARK_HOME/bin/spark-sql --master "local[2]" \
--jars $(ls $ICEBERG_HOME/iceberg-spark-runtime*.jar) \
--conf spark.sql.extensions=org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions \
--conf spark.sql.catalog.ice=org.apache.iceberg.spark.SparkCatalog \
--conf spark.sql.catalog.ice.type=hive \
--conf spark.sql.catalog.ice.uri=thrift://hive-metastore:9083 \
-e "DROP VIEW ice.blog_views.big_orders; DROP VIEW ice.blog_views.discounts;
DROP TABLE ice.blog_views.orders PURGE; DROP NAMESPACE ice.blog_views"' 2>/dev/null
docker exec mc /usr/bin/mc rm --recursive --force minio/warehouse/blog_views.db/
Common misconceptions
“An Iceberg view is portable across engines.” It is designed to be. Whether it is depends on your catalog and both engines’ support for it, and in the setup measured here it was not portable in either direction.
“CREATE OR REPLACE VIEW overwrites the definition.” It adds a version. The
previous definitions stay in the metadata file and the version-log, the last ten
by default.
“The view’s SQL lives in the metastore.” The metastore holds a pointer plus a compatibility copy of the text. The definition of record is the metadata file.
“A view has no schema until you query it.” It has a recorded schema with field ids, and a new one is added when the column list changes.
“Trino cannot do Iceberg views.” Trino created a view happily, just a Trino one, because that is what its Hive-metastore catalog writes. That describes this catalog configuration, not Trino’s view support in general.
Frequently asked questions
How do I tell an Iceberg view from a Hive view?
Check the metastore properties. table_type = ICEBERG-VIEW means there is an
Iceberg metadata file; presto_view = true means a Trino view; neither means a
plain Hive view.
Can I see a view’s previous definitions?
Yes, within limits. The metadata file metadata_location points at holds the last
ten versions and their SQL in versions, and their order in version-log. Older
definitions survive only in older metadata files, for as long as those files do.
Does dropping a column from the underlying table break the view?
Yes, at read time. After ALTER TABLE ... DROP COLUMN discount under a view that
selects it, reading the view failed with [UNRESOLVED_COLUMN.WITH_SUGGESTION] A
column or function parameter with name discount cannot be resolved, while the
view’s recorded schema still listed order_id and discount. The schema is a
record, not a guarantee about the base tables.
Does DROP VIEW clean up storage?
Only partly. Iceberg’s CatalogUtil.dropViewMetadata deletes the view’s current
metadata file, and only when gc.enabled is true; every earlier version’s file
stays. A view created and replaced once left its first 00000-...gz.metadata.json
(490 bytes) in MinIO after DROP VIEW. Remove the view’s folder yourself if the
history should go too.
Do views support time travel? The view is versioned, not the data. Reading through a view reads the current state of the underlying tables. For data-level time travel, the time travel post applies to the tables themselves.
Which catalogs support views? It varies, and this is the question to answer for your own setup first. The Hive Metastore catalog stored them from Spark in this test; a REST catalog is where cross-engine support is most actively developed.
Conclusion
The versioning half of Iceberg views delivers what it claims, within a window. Two replaces left three definitions and two schemas in a metadata file with an audit trail, where a Hive view would have left one string and no history, and the current file keeps the last ten. If you write views from one engine, that is reason enough to prefer them.
The portability half is a design, not yet a guarantee. The spec’s
representations list is built for many dialects, and what actually got written
was one, so the same view that Spark reads fine was, to Trino, an object it
could list, classify as a view, and refuse to query. And Trino’s own view was, to
Spark, an encoded comment.
Which is the useful lesson, and it generalises past views: the format defining something does not mean your catalog and your engines implement it. Between two engines over one metastore, the reliable shared object is still a table.
References
- Iceberg view specification for the metadata structure, versions and representations
- Spark DDL for views for the supported view statements
ViewCatalog.javafor the interface a catalog must implement to support views at all, andHiveViewOperations.javaandViewProperties.javafor the Hive implementation and the version-history defaultTrinoViewHiveMetastore.javafor the Trino view format Trino writes into a Hive Metastore- Trino Iceberg connector for its catalog types and what each supports
- One table, three formats for interoperability at the table level, which is where it currently works
- Iceberg catalogs for why the catalog decides so much about what a table — or a view — can do
Trademarks
Apache Iceberg, Apache Spark, Apache Hive, Apache and the Apache feather logo are either registered trademarks or trademarks of The Apache Software Foundation in the United States and other countries. Trino is a trademark of the Trino Software Foundation.
Found this useful?
These posts and tools are free. If one saved you an afternoon, you can buy me a coffee.