apache polaris is the pick-one architectural decision that finally makes the open lakehouse portable — the vendor-neutral Iceberg REST catalog that Snowflake open-sourced in 2024, donated to the Apache Software Foundation shortly after, and that senior data engineers now evaluate against AWS Glue, Databricks Unity Catalog, Nessie, and the legacy Hive Metastore whenever they design a multi-engine lakehouse. Every Iceberg table your organisation writes — a orders fact from a Snowflake pipeline, a customer_360 model from a Spark DAG, a streaming CDC output from Flink, a Trino federation view — has to be discoverable, authorised, versioned, and safely mutable from any engine without lock-in to the catalog vendor, and it has to do all of that while vending short-lived storage credentials scoped to the exact table the caller has permission to touch. The engineering trade-off does not live in "should we adopt an open catalog" — every multi-engine lakehouse needs one — but in which open iceberg catalog you pick and how it composes with your existing IAM, warehouse, and streaming stack.
This guide is the senior-DE walkthrough you wished existed the first time an interviewer asked "walk me through the iceberg rest catalog spec and why Polaris matters", or "your Databricks Unity Catalog manages the same tables — how do you federate?", or "explain polaris catalog credential vending and why it fixes the storage-IAM story." It walks through the Polaris architecture — the Iceberg REST Catalog Spec 1.6+, namespace hierarchy, principal-role and catalog-role RBAC, storage-credential vending against S3 STS / Azure SAS / GCS signed URLs — the multi-engine access story across Trino, Spark, Flink, and snowflake polaris, the federation model that pulls metadata from Glue / HMS / Unity, and the migration paths (SDK proxy, polaris-catalog-hms shim, UC federation) that move you off your existing catalog without rewriting a single engine. Each section pairs a teaching block with a Solution-Tail interview answer — code, a step-by-step trace, an output table, then a concept-by-concept breakdown of why it works.
When you want hands-on reps immediately after reading, drill the SQL practice library →, rehearse system design against the design practice library →, and sharpen the streaming axis with the streaming practice library →.
On this page
- Why Polaris matters for the open lakehouse in 2026
- Polaris architecture — REST spec + storage credential vending
- RBAC + governance
- Multi-engine access — Trino, Spark, Flink, Snowflake
- Migrating to Polaris + interview signals
- Cheat sheet — Polaris recipes
- Frequently asked questions
- Practice on PipeCode
1. Why Polaris matters for the open lakehouse in 2026
An open Iceberg catalog was the last missing piece of the vendor-neutral lakehouse — Polaris is the reference implementation
The one-sentence invariant: Apache Polaris is the Apache-licensed reference implementation of the Iceberg REST Catalog Spec, open-sourced by Snowflake in 2024 and donated to the ASF, which finally gives every Iceberg-native engine — Trino, Spark, Flink, Dremio, Snowflake, Athena — a shared, vendor-neutral catalog protocol the way S3 / Azure Blob / GCS gave them a shared storage protocol; the pattern you pick in year one becomes the identity boundary, RBAC surface, and audit trail your entire lakehouse hard-codes assumptions against, so the decision matters far beyond "which Iceberg catalog server should I run". Every senior data-engineering interviewer in 2026 asks about catalog choice because it separates architects who understand vendor lock-in from those who've only used one vendor's stack.
The four axes interviewers actually probe.
- Openness and portability. Polaris implements the Iceberg REST Catalog Spec — a public, versioned HTTP protocol that any engine can implement without vendor SDK. Unity Catalog is Databricks-native (open-sourced but still governed by Databricks). Glue is AWS-native. HMS is deployment-heavy legacy. Nessie is git-like but a smaller ecosystem. Interviewers open here because it signals whether you understand that "open source" and "open protocol" are different things.
-
Credential vending model. Polaris vends short-lived, scoped storage credentials (S3 STS
AssumeRolesessions, Azure SAS tokens, GCS signed URLs) so engines never see the long-lived IAM identity that owns the bucket. This is the single biggest security win of the REST-catalog pattern and interviewers love probing it because it collapses the classic "how do you give Spark access to S3?" question. - Federation story. Polaris can federate from an existing Glue / HMS / Unity catalog rather than requiring a big-bang migration. This is the operational reality of a 2026 rollout: most organisations already have thousands of tables in Glue, and greenfield adoption is rare.
- Multi-engine coordination. Iceberg's optimistic-lock commit protocol (compare-and-swap on the current snapshot pointer) means multiple engines can write the same table concurrently — but only if they all talk to the same catalog. Polaris is the shared arbiter; without it, two Spark clusters can silently overwrite each other's snapshots.
The 2026 reality — Polaris is one of four viable open catalog options.
- Apache Polaris. Snowflake-donated, ASF-hosted, Iceberg-REST-first, credential-vending built-in. Production-ready as of the Apache incubator releases; adopted by teams that want to escape single-vendor lock-in without giving up managed convenience. Snowflake also ships it as a managed service called "Snowflake Open Catalog" (the hosted variant).
- Databricks Unity Catalog. Open-sourced in 2024 but still Databricks-governed; supports Iceberg via UniForm; strong on Delta tables. The default when your primary engine is Databricks.
- AWS Glue Data Catalog. AWS-native, mature, integrates deeply with Athena / EMR / Redshift; Iceberg support is second-class relative to Hive. The default when your primary cloud is AWS.
- Project Nessie. Git-like semantics on top of Iceberg — branches, merges, tags — from Dremio. Strong on data-versioning use cases; smaller ecosystem than the other three.
What interviewers listen for.
- Do you name the Iceberg REST Catalog Spec as the protocol Polaris implements, not just "an API"? — senior signal.
- Do you distinguish "open source" from "open protocol" — Polaris is both, Unity Catalog is only the former? — senior signal.
- Do you name credential vending as the security primitive that makes REST catalogs different from HMS-style catalogs? — required answer.
- Do you name federation as the migration bridge from Glue / HMS / Unity, not a big-bang cutover? — senior signal.
- Do you describe the catalog as the shared arbiter for Iceberg's optimistic-lock commits, not just "a metadata store"? — senior signal.
Worked example — the four-catalog comparison table
Detailed explanation. The single most useful artifact for a Polaris interview is a memorised 4x4 comparison table. Every senior open-catalog discussion converges on this table within the first ten minutes; having it in your head is what separates a fluent answer from a stumbling one. Walk through building the table for a hypothetical greenfield lakehouse that needs to serve Trino ad-hoc, Spark batch ETL, and a Snowflake sink.
-
Workload. Multi-engine lakehouse on S3. Tables live in
s3://acme-lake/warehouse/. Engines are Trino for ad-hoc SQL, Spark 3.5 for batch ETL, and Snowflake for governed dashboards. - Requirements. Sub-second table discovery, IAM isolation per team, safe concurrent writes, no vendor lock-in that costs six months of migration when the next tool arrives.
- Constraints. AWS-only for now, ~500 tables at launch, ~50 engineers with three teams that must not see each other's tables.
Question. Build the four-catalog comparison for the greenfield lakehouse and pick the catalog each requirement drives you toward.
Input.
| Catalog | Protocol | Credential vending | Federation | Governance model |
|---|---|---|---|---|
| Apache Polaris | Iceberg REST Spec 1.6 | S3 STS / Azure SAS / GCS signed URLs | from Glue, HMS, Unity | principal-role + catalog-role |
| Databricks Unity Catalog | Unity REST + Iceberg via UniForm | UC-managed IAM | from HMS, Glue (limited) | UC-native ACLs |
| AWS Glue Data Catalog | Glue-SDK + Iceberg extension | IAM identity forwarded | none (source of truth) | IAM + Lake Formation |
| Apache Hive Metastore | Thrift RPC | none (client-side IAM) | none | file-system ACLs + Ranger |
Code.
# Polaris config snippet — a greenfield warehouse catalog
polaris:
server:
port: 8181
catalogs:
- name: warehouse
type: INTERNAL # native Polaris catalog (not federated)
properties:
default-base-location: s3://acme-lake/warehouse/
storage:
type: S3
allowed-locations:
- s3://acme-lake/warehouse/
role-arn: arn:aws:iam::123456789012:role/polaris-warehouse-role
region: us-east-1
auth:
type: OAUTH2
client-id: ${env:POLARIS_CLIENT_ID}
client-secret: ${env:POLARIS_CLIENT_SECRET}
Step-by-step explanation.
- The comparison table forces the four axes into a single view: protocol openness, credential vending, federation, and governance. Polaris wins on protocol openness (it is the Iceberg REST spec's reference implementation), draws with Unity on governance (both offer role-based ACLs), and uniquely offers built-in credential vending on top of native federation from all three legacy sources.
- Unity Catalog is the default answer only when Databricks is your primary compute — its Iceberg support goes through UniForm, which is a translation layer over Delta, and pure Iceberg-native engines still need a Polaris-shaped catalog to talk to.
- Glue is the pragmatic answer when you're already on AWS with hundreds of tables — but its Iceberg extension is bolted on, credential handling is IAM-passthrough (no vending), and multi-engine writes lack the tight optimistic-lock semantics Iceberg was designed for.
- HMS is the legacy answer — every serious lakehouse team already has one and is trying to migrate off it. The
polaris-catalog-hmsshim lets Polaris expose an HMS-shaped interface on the wire, buying you time to migrate engines one at a time. - The greenfield answer for our scenario is Polaris: it gives you Iceberg-REST natively, vends S3 STS credentials so engines never see your bucket owner's IAM, and lets you later federate from Glue if a Redshift team joins that already stores metadata there.
Output.
| Requirement | Recommended catalog | Why |
|---|---|---|
| Multi-engine (Trino + Spark + Snowflake) | Apache Polaris | Iceberg REST is the only shared protocol |
| Strong Databricks integration | Unity Catalog | UC is Databricks-native |
| Deep Athena / Redshift integration | AWS Glue | Glue is AWS-native |
| Zero-touch federation from HMS | Polaris + HMS shim | polaris-catalog-hms bridges the wire protocol |
Rule of thumb. Never pick an open catalog based on "which vendor we already use." Pick it on (protocol openness x credential vending x federation x governance) — the four axes. Write the table on a whiteboard first; the catalog choice falls out of the constraints.
Worked example — what interviewers actually probe on Polaris
Detailed explanation. The senior data-engineering Polaris interview has a predictable structure: the interviewer opens with an ambiguous question ("what's your take on the Iceberg catalog landscape?"), then progressively narrows to test whether you understand the REST spec, credential vending, and federation. The candidates who name the pattern in sentence one score highest; the candidates who describe "just another metadata store" score lowest. Walk through the interview grading rubric.
- Ambiguous opener. "Walk me through how you'd design the catalog layer for a new lakehouse." — invites you to name Polaris and the REST spec.
- Follow-up 1. "How do engines authenticate to storage?" — probes credential vending.
- Follow-up 2. "You already have 3000 tables in Glue — do we start over?" — probes federation.
- Follow-up 3. "How do you stop two Spark jobs from clobbering each other?" — probes optimistic commit + shared catalog.
- Follow-up 4. "How does this compare to Unity Catalog?" — probes protocol vs implementation.
Question. Draft a 5-minute senior Polaris answer that covers all four axes without waiting to be asked.
Input.
| Interview signal | Weak answer | Senior answer |
|---|---|---|
| Catalog named | "we'd use a metadata store" | "Apache Polaris — Iceberg REST Catalog Spec 1.6 reference implementation" |
| Credential model | "engines use their own IAM" | "Polaris vends short-lived STS tokens scoped to the requested table" |
| Federation | "we'd migrate everything" | "federate from Glue via Polaris external catalog; migrate engines one at a time" |
| Multi-engine writes | "we'd serialise them" | "Iceberg optimistic-lock on snapshot_id; Polaris is the arbiter" |
| Vs Unity | "same thing" | "Polaris = open protocol + open source; Unity = open source only, Databricks-governed" |
Code.
Senior Polaris answer template (5 minutes)
==========================================
Minute 1 — name the pattern up front
"I'd default to Apache Polaris as the Iceberg REST catalog, adopting
the Iceberg REST Catalog Spec 1.6 as the wire protocol so every
engine — Trino, Spark, Flink, Snowflake — talks the same language."
Minute 2 — credential vending
"Polaris vends short-lived, scoped storage credentials (S3 STS,
Azure SAS, GCS signed URLs). The engine asks the catalog to load a
table; the catalog checks RBAC and hands back an STS session that
can only read that table's data files, for ~1 hour. Engines never
see the bucket owner's long-lived IAM identity."
Minute 3 — federation
"For any organisation already on Glue or HMS, we federate: Polaris
points at the existing catalog as a read-through and (optionally)
write-through layer, so migration happens engine-by-engine rather
than as a big-bang cutover. The HMS shim (polaris-catalog-hms) is
the ready-made bridge."
Minute 4 — multi-engine coordination
"Iceberg's write protocol is optimistic-lock: each committer reads
the current snapshot_id, writes new data + manifests, then does a
compare-and-swap to advance the pointer. Polaris is the shared
arbiter that all engines CAS against — without a shared catalog,
two Spark jobs can silently overwrite each other."
Minute 5 — governance and vs-Unity comparison
"RBAC is principal -> principal_role -> catalog_role -> grants;
grants map to vended-credential scope so 'this user can SELECT'
translates into 'this STS session can s3:GetObject only these
prefixes.' Compared to Unity Catalog: Polaris is an open protocol
plus open source; Unity is open source but Databricks-governed,
and its Iceberg story goes through UniForm."
Step-by-step explanation.
- Minute 1 is the crucial framing. Naming the spec ("Iceberg REST Catalog Spec 1.6") signals you understand this is a protocol decision, not a vendor decision. Weak candidates dive into tools ("we'd use a Docker image of…") before naming the wire protocol.
- Minute 2 preempts the credential-vending question because it's the single most differentiating feature. Every senior interviewer wants to hear "STS session scoped to the table"; saying "IAM identity forwarded" is a red flag.
- Minute 3 addresses the migration reality — nobody starts greenfield. Federation is what makes Polaris adoption tractable at organisations that already have thousands of tables.
- Minute 4 covers the multi-engine coordination story. The optimistic-lock CAS on snapshot_id is Iceberg-native, but it only works if all writers share a catalog; Polaris fulfils that role.
- Minute 5 handles the inevitable Unity Catalog comparison. The key phrase is "open protocol plus open source" — this is what separates Polaris from Unity Catalog architecturally.
Output.
| Grading criterion | Weak score | Senior score |
|---|---|---|
| Names Iceberg REST Spec in minute 1 | rare | mandatory |
| Names STS vending in minute 2 | occasional | required |
| Names federation in minute 3 | rare | senior signal |
| Names optimistic-lock CAS | rare | senior signal |
| Distinguishes Polaris vs Unity | rare | senior signal |
Rule of thumb. The senior Polaris answer is a 5-minute monologue that covers protocol, vending, federation, coordination, and vs-Unity without waiting for the follow-ups. Rehearse it once; deploy it every time.
Worked example — the "pick the catalog" decision tree
Detailed explanation. Given a new lakehouse or a migration project, the senior architect runs a 5-question decision tree in their head. Codifying the tree makes the interview answer reproducible: any interviewer can hand you a scenario and you can walk it out loud. Walk through the tree with three canonical scenarios: greenfield multi-engine on AWS, Databricks-heavy org adding Trino, and a Snowflake-only shop preparing for open-table access.
- Q1. Are you multi-engine (more than one Iceberg-native compute)? -> yes = Polaris; no = go to Q2.
- Q2. Is Databricks your primary engine? -> yes = Unity Catalog (with UniForm for Iceberg); no = go to Q3.
- Q3. Are you AWS-only and Athena/Redshift-heavy? -> yes = Glue with Iceberg extension; no = go to Q4.
- Q4. Do you need git-like branch/merge on tables? -> yes = Nessie; no = Polaris (safe default).
- Q5 (parallel branch). Do you already have a legacy catalog with thousands of tables? -> yes = federate first, migrate engines one at a time.
Question. Walk the decision tree for the three scenarios and record the catalog each ends up with.
Input.
| Scenario | Q1 (multi-engine?) | Q2 (Databricks-primary?) | Q3 (AWS-only?) | Q5 (legacy catalog?) |
|---|---|---|---|---|
| Greenfield multi-engine on AWS | yes | no | no | no |
| Databricks-heavy adding Trino | yes | yes | no | yes (HMS) |
| Snowflake-only opening to Trino | yes (soon) | no | no | no |
Code.
# Decision-tree helper (illustrative)
def pick_open_catalog(is_multi_engine: bool,
databricks_primary: bool,
aws_only: bool,
needs_branch_merge: bool,
has_legacy_catalog: bool) -> list[str]:
"""Return the primary open-catalog choice(s) for a lakehouse."""
choices: list[str] = []
if is_multi_engine and not databricks_primary:
choices.append("apache polaris")
elif databricks_primary:
choices.append("unity catalog (with UniForm for Iceberg)")
elif aws_only:
choices.append("aws glue (iceberg extension)")
elif needs_branch_merge:
choices.append("nessie")
else:
choices.append("apache polaris") # safe default
if has_legacy_catalog:
choices.append("federate from legacy first (polaris-catalog-hms or UC federation)")
return choices
# Walk the three scenarios
print(pick_open_catalog(True, False, False, False, False))
# -> ['apache polaris']
print(pick_open_catalog(True, True, False, False, True))
# -> ['unity catalog (with UniForm for Iceberg)',
# 'federate from legacy first (polaris-catalog-hms or UC federation)']
print(pick_open_catalog(True, False, False, False, False))
# -> ['apache polaris']
Step-by-step explanation.
- Scenario 1 — greenfield multi-engine on AWS with Trino, Spark, and Snowflake all touching the same lake. Q1 short-circuits to Polaris. This is the modern default for any vendor-neutral lakehouse.
- Scenario 2 — a Databricks-heavy org where the primary compute is Databricks but a Trino cluster is being added for ad-hoc SQL. Q2 = yes gives Unity Catalog with UniForm; but the legacy HMS the Trino team already uses means Q5 fires too — federate first, migrate over 6-12 months.
- Scenario 3 — Snowflake shop opening its Iceberg tables to Trino for ad-hoc access. Snowflake ships the managed "Snowflake Open Catalog" service, which is Polaris under the hood. The decision tree hits Polaris naturally because Q1 = yes.
- The parallel Q5 branch (legacy federation) is orthogonal to the primary choice. You can pair federation with any target catalog; Polaris + Glue federation is the most common bridge because Glue is the biggest installed base.
- If none of Q1-Q4 pass and you're single-engine on Snowflake-only or Databricks-only with no plans to change, you might not need an open catalog at all — the vendor-native catalog is fine. But interviewers will still expect you to explain why you didn't pick one.
Output.
| Scenario | Primary catalog | Add-on |
|---|---|---|
| Greenfield multi-engine on AWS | apache polaris | — |
| Databricks-heavy adding Trino | unity catalog + UniForm | federate HMS first |
| Snowflake-only opening to Trino | snowflake open catalog (polaris) | — |
Rule of thumb. The five-question decision tree is a whiteboard-friendly answer. Practice walking it end-to-end so an interviewer can hand you any scenario and get a catalog name in under 60 seconds.
Senior interview question on open-catalog selection
A senior interviewer often opens with: "You are the tech lead for a 100-person data platform team that currently runs 3000 Iceberg tables in AWS Glue, with Spark on EMR as the primary writer and Athena as the primary reader. Leadership wants to add Trino for ad-hoc SQL, Snowflake for governed dashboards, and a Databricks proof-of-concept — all reading and writing the same Iceberg tables. Walk me through the open-catalog decision, the migration path, the credential-vending story, and the failure modes you'd guard against."
Solution Using Apache Polaris with Glue federation, S3 STS vending, and per-team RBAC
# Step 1 — deploy Polaris (Docker Compose for a proof-of-concept)
version: "3.9"
services:
polaris:
image: apache/polaris:latest
ports:
- "8181:8181"
environment:
POLARIS_PERSISTENCE_TYPE: postgres
POLARIS_PERSISTENCE_URL: jdbc:postgresql://postgres:5432/polaris
POLARIS_OAUTH2_ISSUER: https://sso.acme.internal/realms/data
AWS_REGION: us-east-1
depends_on:
- postgres
postgres:
image: postgres:16
environment:
POSTGRES_DB: polaris
POSTGRES_USER: polaris
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- polaris-pg:/var/lib/postgresql/data
volumes:
polaris-pg:
# Step 2 — create the federated Glue catalog inside Polaris
curl -X POST https://polaris.acme.internal/api/management/v1/catalogs \
-H "Authorization: Bearer $POLARIS_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "warehouse",
"type": "EXTERNAL",
"properties": {
"catalog-impl": "org.apache.iceberg.aws.glue.GlueCatalog",
"default-base-location": "s3://acme-lake/warehouse/",
"glue.region": "us-east-1",
"glue.catalog-id": "123456789012"
},
"storageConfigInfo": {
"storageType": "S3",
"roleArn": "arn:aws:iam::123456789012:role/polaris-warehouse-role",
"allowedLocations": ["s3://acme-lake/warehouse/"]
}
}'
-- Step 3 — grant a team the ability to read one namespace
-- (executed via Polaris management API; SQL shown for readability)
CREATE PRINCIPAL analyst_alice CLIENT_ID 'alice';
CREATE PRINCIPAL_ROLE analyst_role;
CREATE CATALOG_ROLE warehouse_reader ON CATALOG warehouse;
GRANT NAMESPACE_READ_PROPERTIES, TABLE_READ_DATA
ON NAMESPACE warehouse.sales
TO CATALOG_ROLE warehouse_reader;
GRANT CATALOG_ROLE warehouse_reader
TO PRINCIPAL_ROLE analyst_role;
GRANT PRINCIPAL_ROLE analyst_role
TO PRINCIPAL analyst_alice;
Step-by-step trace.
| Step | Before (Glue-only) | After (Polaris + Glue federation) |
|---|---|---|
| Wire protocol | Glue SDK per engine | Iceberg REST (one protocol, all engines) |
| Credential model | each engine gets long-lived IAM | Polaris vends short-lived STS per request |
| Multi-engine writes | Glue eventual consistency risk | Polaris arbitrates optimistic-lock CAS |
| Add new engine (Trino) | wire Glue-SDK plugin | point at Polaris REST endpoint |
| Migration path | big-bang cutover | engine-by-engine over 6-12 months |
| RBAC | IAM + Lake Formation | Polaris principal/role/grant |
| Failure surface | IAM misconfig leaks entire bucket | vended credential scoped to one prefix |
After the rollout, all 3000 tables remain in Glue as the source of truth; Polaris exposes them via Iceberg REST; Trino / Spark / Snowflake / Databricks all point at Polaris. Alice (an analyst) receives a short-lived STS token that can read only s3://acme-lake/warehouse/sales/; her Trino query works and her IAM identity never touches S3 directly.
Output:
| Metric | Before | After |
|---|---|---|
| Wire protocols in play | 1 (Glue-SDK) | 1 (Iceberg REST) |
| Engines supported | EMR + Athena | +Trino +Snowflake +Databricks |
| Credential lifetime | permanent IAM | ~1 h STS |
| RBAC surface | IAM + Lake Formation | Polaris principals + catalog roles |
| Migration windows required | 1 big-bang | 0 (engine-by-engine) |
Why this works — concept by concept:
- Iceberg REST Catalog Spec — a public, versioned HTTP protocol every Iceberg-native engine already speaks. Standardising on the wire protocol decouples engine choice from catalog choice; you can swap either side without touching the other.
-
EXTERNAL catalog federation — pointing a Polaris catalog at Glue with
catalog-impl=GlueCatalogmakes Polaris a read-through (and optionally write-through) proxy. No table migration; Polaris asks Glue, translates the response into Iceberg-REST shape, returns to the engine. -
Storage credential vending — Polaris holds the bucket owner's
roleArn; when an engine loads a table, PolarisAssumeRolewith a scoped session policy that whittles the permissions down to the requested prefix. The engine gets an STS session; the bucket owner's identity never leaves Polaris. - Principal -> principal_role -> catalog_role -> grant — the four-level model separates who (principal), what team (principal_role), what scope (catalog_role), and what privilege (grant). Grants map to the vended-credential scope so the "SELECT on sales" grant becomes "s3:GetObject on s3://.../sales/*".
-
Cost — one Polaris deployment (~2 vCPU + 4 GB), one Postgres for state, one bucket-owner IAM role that Polaris
AssumeRoles. The eliminated cost is per-engine IAM configuration, per-engine catalog plugin, and per-team Lake Formation policy sprawl. Net O(1) per lakehouse rather than O(engines x teams x tables).
Design
Topic — design
Design problems on open lakehouse catalogs
2. Polaris architecture — REST spec + storage credential vending
The Iceberg REST Catalog Spec plus vended STS sessions collapses "engine talks to storage" into a single, auditable identity boundary
The mental model in one line: Polaris is an HTTP server that speaks the Iceberg REST Catalog Spec on the front side, holds table metadata in Postgres (or the storage-agnostic persistence layer of your choice) in the middle, and vends short-lived, scoped storage credentials (S3 STS AssumeRole sessions, Azure SAS tokens, GCS signed URLs) on the back side so that engines never receive the long-lived IAM identity that owns the bucket — the same request that returns table metadata also returns the exact credentials needed to read that table's data files, for the duration of that read, and no more. Every senior architect must understand the request lifecycle end-to-end because it is the single most differentiating feature of Polaris relative to Glue or HMS.
The four architectural layers of Polaris.
-
Front — Iceberg REST Catalog Spec. Every HTTP verb is defined by the spec:
GET /v1/{prefix}/namespaces,GET /v1/{prefix}/namespaces/{ns}/tables,POST /v1/{prefix}/namespaces/{ns}/tables/{table}(commit),POST /v1/{prefix}/oauth/tokens(OAuth2). Every Iceberg-native engine implements the client side of this spec, so Polaris "just works" with them out of the box. - Middle — persistence + policy. Polaris stores catalogs, namespaces, tables, principals, roles, and grants in a relational persistence layer (Postgres is the reference implementation; Snowflake's managed variant uses a proprietary store). Every REST call resolves to a policy decision plus a metadata read/write.
-
Back — storage credential vending. Polaris holds one long-lived "catalog identity" IAM role per storage location; on every table load or write, Polaris
AssumeRolewith a scoped session policy that narrows the permissions to the requested table's prefix, and hands the resulting STS session to the caller. -
Federation adapter (optional). For
EXTERNALcatalogs, Polaris does not store metadata itself — it delegates to Glue, HMS, or another Iceberg catalog. The client still sees an Iceberg REST endpoint; the back end is proxied.
The end-to-end request lifecycle.
-
1. OAuth2 handshake. Engine POSTs
client_id+client_secretto/oauth/tokens; Polaris returns a bearer token scoped to the principal. -
2. Namespace listing. Engine calls
GET /v1/warehouse/namespaces; Polaris checks the token, resolves the principal to catalog roles, filters namespaces by grant, returns the list. -
3. Table load. Engine calls
GET /v1/warehouse/namespaces/sales/tables/orders; Polaris returns the Iceberg table metadata plus aconfigblock containing STS credentials scoped to that table's storage location. -
4. Data read. Engine uses the STS credentials to
s3:GetObjectthe data files directly — no proxying through Polaris. - 5. Commit. Engine writes new data files, then POSTs a commit request; Polaris CAS-updates the current snapshot pointer or rejects on conflict.
The Iceberg REST spec endpoints you must know.
-
POST /v1/oauth/tokens— OAuth2 token exchange (client_credentials or refresh). -
GET /v1/config— catalog capabilities + endpoint version. -
GET /v1/{prefix}/namespaces— list namespaces. -
POST /v1/{prefix}/namespaces— create a namespace. -
GET /v1/{prefix}/namespaces/{ns}/tables— list tables in a namespace. -
GET /v1/{prefix}/namespaces/{ns}/tables/{table}— load a table (returns metadata + vended credentials). -
POST /v1/{prefix}/namespaces/{ns}/tables/{table}— commit an update (optimistic-lock CAS). -
DELETE /v1/{prefix}/namespaces/{ns}/tables/{table}— drop a table.
Common interview probes on Polaris architecture.
- "Walk me through what happens when Trino issues
SELECT * FROM warehouse.sales.orders." — required answer: OAuth token, namespace check, table load, vended STS, direct S3 read. - "How does Polaris hold state?" — Postgres (reference) or the storage-agnostic persistence SPI; Snowflake-managed uses its internal store.
- "What is
catalog-implin an EXTERNAL catalog config?" — the federation adapter class; e.g.GlueCatalogfor Glue federation. - "Where do you plug in a custom credential vending policy?" — Polaris's storage-config SPI plus your organisation's
AssumeRolepolicy conditions.
Worked example — the Iceberg REST catalog endpoints as an HTTP session
Detailed explanation. The clearest way to internalise the REST spec is to watch an engine talk to Polaris. Turn on debug logging in a Spark client, issue a single SELECT, and read the sequence of HTTP calls. Walk through the four canonical calls: token exchange, namespace list, table load, and commit.
-
The engine. Spark 3.5 with the
iceberg-spark-runtime-3.5_2.12package andspark.sql.catalog.warehouse.type=rest. -
The catalog. Polaris deployed at
https://polaris.acme.internal. -
The query.
SELECT count(*) FROM warehouse.sales.orders. - What we observe. Four HTTP calls before a single S3 GET happens.
Question. Show the four HTTP calls Spark makes to Polaris (with headers and bodies) and label which piece of the Iceberg REST spec each satisfies.
Input.
| Call | Endpoint | Purpose |
|---|---|---|
| 1 | POST /v1/oauth/tokens |
acquire bearer token |
| 2 | GET /v1/warehouse/config |
negotiate protocol version |
| 3 | GET /v1/warehouse/namespaces/sales/tables/orders |
load table + credentials |
| 4 | (POST /v1/.../orders — only on write) |
commit new snapshot |
Code.
### 1. OAuth2 token exchange (client_credentials grant)
POST /v1/oauth/tokens HTTP/1.1
Host: polaris.acme.internal
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=spark-etl&client_secret=****&scope=PRINCIPAL_ROLE:etl_writer
HTTP/1.1 200 OK
Content-Type: application/json
{
"access_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 3600
}
### 2. Catalog config negotiation
GET /v1/warehouse/config HTTP/1.1
Host: polaris.acme.internal
Authorization: Bearer eyJhbGciOi...
HTTP/1.1 200 OK
Content-Type: application/json
{
"defaults": { "clients": "2" },
"overrides": { "warehouse": "s3://acme-lake/warehouse/" },
"endpoints": ["GET /v1/{prefix}/namespaces", "GET /v1/{prefix}/namespaces/{ns}/tables/{tbl}", "..."]
}
### 3. Table load — returns metadata + vended STS credentials
GET /v1/warehouse/namespaces/sales/tables/orders HTTP/1.1
Host: polaris.acme.internal
Authorization: Bearer eyJhbGciOi...
HTTP/1.1 200 OK
Content-Type: application/json
{
"metadata-location": "s3://acme-lake/warehouse/sales/orders/metadata/00042-abcd.metadata.json",
"metadata": { "format-version": 2, "table-uuid": "...", "location": "...", "schemas": [...], "current-snapshot-id": 123456 },
"config": {
"s3.access-key-id": "ASIA...",
"s3.secret-access-key": "****",
"s3.session-token": "IQoJb3JpZ...",
"client.region": "us-east-1"
}
}
# (Sketch) Spark uses the config block to build an S3 client with the
# vended credentials, then reads Parquet directly from S3 — Polaris is
# not in the data path.
def load_iceberg_table(rest_url, catalog, namespace, table, token):
r = requests.get(f"{rest_url}/v1/{catalog}/namespaces/{namespace}/tables/{table}",
headers={"Authorization": f"Bearer {token}"})
payload = r.json()
metadata = payload["metadata"] # table snapshot chain
creds = payload["config"] # STS session
s3 = boto3.client("s3",
aws_access_key_id=creds["s3.access-key-id"],
aws_secret_access_key=creds["s3.secret-access-key"],
aws_session_token=creds["s3.session-token"])
return metadata, s3
Step-by-step explanation.
- The OAuth2 token exchange is the only stateful handshake — every subsequent call carries the bearer token. Tokens are typically 1-hour TTL; long-running Spark jobs handle refresh via the standard OAuth2 refresh grant.
- The config call negotiates protocol version and returns overrides — the catalog can tell the engine which warehouse root to use, which endpoints are supported, and which default settings apply. This lets the same client work against catalogs at different spec versions.
- The table-load call is the whole architecture in one response: the client gets back Iceberg table metadata (schema, snapshot chain, partition spec) and an ephemeral STS session that authorises reading that table's data files. No separate "get me an S3 credential" step.
- Once the engine has the STS credentials, it talks directly to S3 for every Parquet file read. Polaris is out of the data path — the catalog is only in the metadata path. This is what makes REST catalogs scale linearly with engine count rather than becoming a bottleneck.
- Commit (call 4, on writes only) is the CAS: the client POSTs the new snapshot proposal with the current snapshot_id as a precondition; Polaris either advances the pointer or returns 409 Conflict. The engine retries with the updated snapshot.
Output.
| Call | Endpoint | Response type | Data path? |
|---|---|---|---|
| OAuth2 token | POST /v1/oauth/tokens |
bearer + expires_in | no |
| Config | GET /v1/{prefix}/config |
version + overrides | no |
| Table load | GET /v1/{prefix}/namespaces/{ns}/tables/{tbl} |
metadata + vended creds | no |
| Commit | POST /v1/{prefix}/namespaces/{ns}/tables/{tbl} |
200 or 409 conflict | no |
| Data read | direct S3 GetObject
|
Parquet bytes | yes |
Rule of thumb. Learn the four canonical Iceberg REST calls (token, config, table load, commit) by heart. If you can describe the request/response shape of each, you can debug any engine-to-Polaris integration without reading the vendor's docs.
Worked example — S3 STS credential vending with a scoped session policy
Detailed explanation. The credential-vending story is worth understanding at the AWS API level because it is the single most misunderstood piece of Polaris. Polaris holds a catalog-owner IAM role (say, polaris-warehouse-role) that has broad access to the warehouse bucket; on every table load, it AssumeRole with a session policy that whittles the permissions down to the exact prefix of the requested table. The caller gets an STS session with the intersection of the role policy and the session policy — narrow, time-bounded, auditable.
-
The catalog-owner role.
polaris-warehouse-role— attached to Polaris; can read/write anywhere unders3://acme-lake/warehouse/. -
The scoping. For a request to load
warehouse.sales.orders, Polaris passes a session policy limiting the STS session tos3://acme-lake/warehouse/sales/orders/*. - The duration. ~1 hour by default; short enough that a leaked token expires quickly, long enough for typical query lifetimes.
Question. Show the AWS API calls Polaris makes when an engine loads a table, and the resulting scoped STS session.
Input.
| Component | Value |
|---|---|
| Catalog role ARN | arn:aws:iam::123456789012:role/polaris-warehouse-role |
| Table location | s3://acme-lake/warehouse/sales/orders/ |
| Requested privilege | TABLE_READ_DATA |
| Session duration | 3600 seconds |
Code.
# Polaris internal — vending an S3 STS session for a table load
import boto3
import json
def vend_credentials_for_table(role_arn: str,
table_location: str,
privilege: str,
duration_seconds: int = 3600) -> dict:
"""Return a scoped STS session that can only touch this table's prefix."""
# 1. Build a scoped session policy from the requested privilege
if privilege == "TABLE_READ_DATA":
actions = ["s3:GetObject", "s3:ListBucket"]
elif privilege == "TABLE_WRITE_DATA":
actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"]
else:
raise PermissionError(privilege)
bucket, _, prefix = table_location.replace("s3://", "").partition("/")
session_policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": actions,
"Resource": [
f"arn:aws:s3:::{bucket}",
f"arn:aws:s3:::{bucket}/{prefix}*",
],
"Condition": {
"StringLike": {"s3:prefix": [f"{prefix}*"]}
} if "ListBucket" in actions else {}
}
]
}
# 2. AssumeRole with the session policy — STS intersects with role policy
sts = boto3.client("sts")
resp = sts.assume_role(
RoleArn=role_arn,
RoleSessionName=f"polaris-{privilege.lower()}",
Policy=json.dumps(session_policy),
DurationSeconds=duration_seconds,
)
c = resp["Credentials"]
return {
"s3.access-key-id": c["AccessKeyId"],
"s3.secret-access-key": c["SecretAccessKey"],
"s3.session-token": c["SessionToken"],
"s3.expiration": c["Expiration"].isoformat(),
}
// Effective permissions of the vended STS session (intersection of role + session policy)
{
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::acme-lake",
"arn:aws:s3:::acme-lake/warehouse/sales/orders/*"
]
}
]
}
Step-by-step explanation.
- The catalog-owner role (
polaris-warehouse-role) has broad permissions — Polaris needs it in order to serve any table under the warehouse. This role is the trust boundary; if it leaks, the whole warehouse leaks. Guard it with IAM instance profiles, Secrets Manager, and audit logging. - The session policy is built dynamically per request. For a
TABLE_READ_DATAprivilege onwarehouse/sales/orders, the policy restrictss3:GetObjecttoacme-lake/warehouse/sales/orders/*. Even though the role can access the whole warehouse, the STS session can only access this one prefix. - AWS STS
AssumeRolecomputes the intersection of the role's identity policy and the session policy — the caller gets whichever is more restrictive. This is the credential-vending guarantee: the token cannot do more than the session policy allows, even if the role could. -
DurationSeconds=3600(1 hour) is the default; the maximum is 12 hours (bounded by the role'sMaxSessionDuration). Short TTLs limit blast radius; long TTLs reduce token-refresh overhead. Most Polaris deployments settle at 1-3 hours. - The vended session becomes the
configblock in the table-load response. The engine builds an S3 client with these credentials and reads Parquet directly — Polaris is out of the data path. Every subsequent read is authorised by AWS, not by Polaris; Polaris only decides once, at credential vend time.
Output.
| Aspect | Catalog-owner role | Vended STS session |
|---|---|---|
| Scope | s3://acme-lake/warehouse/* |
s3://acme-lake/warehouse/sales/orders/* |
| Actions | Get/Put/Delete/List | Get/List (read-only) |
| Duration | permanent | 1 hour |
| Blast radius on leak | whole warehouse | one table, one hour |
| Who sees it | Polaris only | engine (and its logs, sadly) |
Rule of thumb. Never let engines hold long-lived IAM identities to a lakehouse bucket. Let Polaris AssumeRole a broad catalog-owner role and vend narrow, short-lived STS sessions per table load. Every credential leak is scoped to one table for one hour rather than to your entire lake.
Worked example — namespace hierarchy and dot-notation resolution
Detailed explanation. Polaris namespaces are hierarchical: catalog.parent_ns.child_ns.table. Every engine's identifier resolution walks the hierarchy on the way in; every RBAC grant can target any level of the hierarchy on the way out. Getting the namespace design right is the difference between "grants are easy" and "we've spent six months writing custom RBAC scripts." Walk through a realistic 3-team, 200-table namespace layout.
-
Layout.
warehouse.sales.orders,warehouse.sales.customers,warehouse.finance.gl_entries,warehouse.marketing.campaigns. -
Teams.
sales_team,finance_team,marketing_team— each owns one top-level namespace. - Cross-team access. Analytics has read on everything; ETL service accounts have write on their own team.
Question. Design the namespace hierarchy for a 3-team lakehouse and show how grants propagate.
Input.
| Namespace | Owner team | Read grants | Write grants |
|---|---|---|---|
warehouse.sales |
sales_team | analytics_readers | sales_etl |
warehouse.finance |
finance_team | analytics_readers | finance_etl |
warehouse.marketing |
marketing_team | analytics_readers | marketing_etl |
Code.
# Create the namespaces via the Iceberg REST spec
curl -X POST https://polaris.acme.internal/v1/warehouse/namespaces \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"namespace": ["sales"], "properties": {"owner": "sales_team"}}'
curl -X POST https://polaris.acme.internal/v1/warehouse/namespaces \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"namespace": ["finance"], "properties": {"owner": "finance_team"}}'
# Nested namespace (rare but supported)
curl -X POST https://polaris.acme.internal/v1/warehouse/namespaces \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"namespace": ["sales", "raw"], "properties": {"tier": "bronze"}}'
-- Analyst grant — read across all three top-level namespaces
CREATE CATALOG_ROLE analytics_readers ON CATALOG warehouse;
GRANT NAMESPACE_READ_PROPERTIES, TABLE_READ_DATA
ON NAMESPACE warehouse.sales TO CATALOG_ROLE analytics_readers;
GRANT NAMESPACE_READ_PROPERTIES, TABLE_READ_DATA
ON NAMESPACE warehouse.finance TO CATALOG_ROLE analytics_readers;
GRANT NAMESPACE_READ_PROPERTIES, TABLE_READ_DATA
ON NAMESPACE warehouse.marketing TO CATALOG_ROLE analytics_readers;
-- ETL service account — write on one namespace only
CREATE CATALOG_ROLE sales_etl ON CATALOG warehouse;
GRANT NAMESPACE_CREATE_TABLE, TABLE_WRITE_DATA, TABLE_READ_DATA
ON NAMESPACE warehouse.sales
TO CATALOG_ROLE sales_etl;
-- Grants propagate to nested namespaces (opt-in via inheritance flag)
GRANT NAMESPACE_READ_PROPERTIES, TABLE_READ_DATA
ON NAMESPACE warehouse.sales.raw
TO CATALOG_ROLE analytics_readers;
Step-by-step explanation.
- Every namespace is a first-class resource with its own properties (
owner,tier, etc.) and its own RBAC surface. The three-level pattern (catalog.team.table) keeps grants aligned with team ownership and makes the org chart legible in the namespace tree. - Nested namespaces (
warehouse.sales.raw) are supported but should be used sparingly — every additional level is one more grant surface. Prefer flat when possible; go deep only when the domain has a natural hierarchy (bronze/silver/gold, region-based, etc.). - The
analytics_readerscatalog role has three separate namespace-level grants — one per top-level namespace. This is explicit rather than "grant on catalog"; explicit grants are easier to audit and revoke. - The
sales_etlcatalog role has write onwarehouse.salesonly. Iceberg REST maps this tos3:PutObjectons3://acme-lake/warehouse/sales/*when Polaris vends credentials. A write attempt towarehouse.financereturns 403 at the REST layer before any S3 call. - Grant inheritance is opt-in in Polaris — child namespaces do not automatically inherit parent grants. This is a deliberate design choice: it makes the security model explicit and prevents "surprised" permissions. If you want inheritance, use the
inheritableflag on the grant.
Output.
| Principal | Namespace request | Result |
|---|---|---|
| analyst (analytics_readers) | READ warehouse.sales.orders
|
200 + read STS |
| analyst (analytics_readers) | WRITE warehouse.sales.orders
|
403 no grant |
| sales_etl | WRITE warehouse.sales.orders
|
200 + write STS |
| sales_etl | READ warehouse.finance.gl_entries
|
403 no grant |
| unnamed principal | anything | 401 no bearer token |
Rule of thumb. Design the namespace hierarchy around team ownership first, then within a team around lifecycle stage (bronze/silver/gold). Every additional namespace level is one more grant surface — prefer flat when possible. Explicit grants beat inherited grants for auditability.
Senior interview question on Polaris architecture
A senior interviewer might ask: "A Spark job in team A hangs for 90 seconds every time it opens a large Iceberg table via Polaris; a Trino query on the same table returns in 200 ms. Walk me through the four-call Iceberg REST lifecycle, identify the likely bottleneck, and show how you'd instrument Polaris to prove it. Include the credential-vending path and how you'd validate the STS scope hasn't accidentally widened."
Solution Using the Iceberg REST call sequence, Polaris metrics, and CloudTrail STS validation
# 1. Instrument the Spark client to log every REST call
import time
import requests
class TimingIcebergRestClient:
def __init__(self, base_url: str, token: str):
self.base_url = base_url
self.session = requests.Session()
self.session.headers.update({"Authorization": f"Bearer {token}"})
def _get(self, path: str):
t0 = time.perf_counter()
r = self.session.get(f"{self.base_url}{path}")
dt_ms = (time.perf_counter() - t0) * 1000
print(f"REST {r.status_code} {path} {dt_ms:.1f} ms body={len(r.content)}B")
r.raise_for_status()
return r.json()
def load_table(self, catalog: str, ns: str, table: str):
return self._get(f"/v1/{catalog}/namespaces/{ns}/tables/{table}")
# 2. Polaris metrics scrape config (Prometheus) — every REST endpoint is timed
scrape_configs:
- job_name: polaris
static_configs:
- targets: ["polaris.acme.internal:8181"]
metrics_path: /metrics
# Key metrics exported by Polaris:
# polaris_rest_request_seconds_bucket{endpoint="loadTable",...}
# polaris_credential_vend_seconds_bucket{storage="S3",...}
# polaris_persistence_seconds_bucket{op="get_table_metadata",...}
# 3. Confirm the vended STS session scope via CloudTrail
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=EventName,AttributeValue=AssumeRole \
--start-time "$(date -u -v-1H '+%Y-%m-%dT%H:%M:%SZ')" \
--end-time "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
--output json \
| jq '.Events[] | select(.Username | contains("polaris")) |
{time: .EventTime,
session_name: .CloudTrailEvent | fromjson | .requestParameters.roleSessionName,
policy: .CloudTrailEvent | fromjson | .requestParameters.policy}'
Step-by-step trace.
| Layer | Observation | Diagnosis |
|---|---|---|
| Client trace |
POST /oauth/tokens — 40 ms |
fast; token acquired |
| Client trace |
GET /warehouse/config — 10 ms |
fast; config cached |
| Client trace |
GET /warehouse/namespaces/sales/tables/orders — 85 000 ms |
slow — bottleneck here |
| Polaris metrics |
polaris_credential_vend_seconds{storage="S3"} p99 = 75 s |
STS is slow |
| Polaris metrics |
polaris_persistence_seconds{op="get_table_metadata"} p99 = 8 s |
Postgres is slow too |
| CloudTrail |
AssumeRole succeeded; scope = correct prefix only |
scope is not widened |
| Root cause | STS throttling from too many concurrent AssumeRole calls | shared role saturation |
The trace shows the 90-second hang lives entirely in the table-load call — split roughly between STS AssumeRole throttling (75 s) and metadata fetch (8 s). CloudTrail proves the vended session scope is correct — the delay is not a security regression. The fix is to (a) add STS regional endpoint config, (b) split the catalog-owner role into per-team roles to reduce AssumeRole contention, and (c) tune the Postgres pool size on Polaris.
Output:
| Metric | Value |
|---|---|
| Total REST calls per table load | 3 (token cached) |
| Table-load latency (steady state) | 200-500 ms |
| Table-load latency (throttled) | 75-90 s |
| Vended STS scope | table prefix only (verified) |
| STS session TTL | 3600 s |
| Concurrent AssumeRole limit | 300/s per account (AWS default) |
Why this works — concept by concept:
- Iceberg REST call sequence — the four-call pattern (token, config, load, commit) is instrumentable at both client and server. Timing each call in isolation localises the bottleneck to a single hop rather than "somewhere in the stack."
-
Polaris server metrics — Polaris exports Prometheus histograms per endpoint plus per storage-vending backend.
polaris_credential_vend_secondsisolates STS latency from metadata latency; without it you'd chase Postgres when the fault is AWS-side. -
CloudTrail AssumeRole audit — every
AssumeRolecall is logged with the session name and policy. Grep forpolarisin the session name and you can prove (or disprove) that the vended scope is what you expect. Never trust that "the code is right"; verify at the AWS API layer. -
STS throttling as a shared-role failure — AWS caps
AssumeRoleat 300 requests/second per account. A single busy Polaris deployment can saturate that; splitting into per-team roles gives each team its own quota bucket. - Cost — one Polaris instance (~2 vCPU + 4 GB), one Postgres for state, one metrics scrape, one CloudTrail lookup per incident. The eliminated cost is engineers guessing where the 90-second hang lives. Net O(1) diagnostic time per issue rather than O(hours).
Design
Topic — design
Design problems on REST-based catalog systems
3. RBAC + governance
polaris rbac is a four-level model — principal, principal_role, catalog_role, grant — that maps cleanly onto vended-credential scope
The mental model in one line: Polaris RBAC is a four-level composition where a principal (an authenticated identity) is granted one or more principal_roles (organisational groupings), each principal_role is granted one or more catalog_roles (per-catalog permission bundles), and each catalog_role receives explicit grants on catalogs / namespaces / tables — the composition of these grants determines which STS scope Polaris will vend on a table load and thereby which S3 prefixes the caller can actually touch, so every "why can't Alice read this table" investigation walks the same four levels in reverse. Every senior architect must be able to walk that chain out loud from either direction.
The four RBAC levels — why each exists.
-
Principal. The authenticated identity — either a human user (via SSO / OIDC) or a service account (via OAuth2 client credentials). Each principal has a
client_id; theclient_secretis never stored server-side. -
Principal role. An organisational grouping —
analyst,etl_writer,data_admin. Principals are added to principal roles; principal roles are the "team" layer. Grants are not attached to principal roles directly. -
Catalog role. A per-catalog permission bundle —
warehouse_reader,warehouse_writer,warehouse_admin. Catalog roles hold the actual grants and are scoped to one catalog. -
Grant. The atomic privilege —
TABLE_READ_DATA,NAMESPACE_CREATE_TABLE,CATALOG_MANAGE_ACCESS. Grants are attached to catalog roles and target catalogs / namespaces / tables.
The privilege taxonomy every architect must memorise.
-
Catalog-level.
CATALOG_MANAGE_CONTENT(create/drop namespaces),CATALOG_MANAGE_ACCESS(grant/revoke inside this catalog),CATALOG_MANAGE_METADATA(change catalog properties). -
Namespace-level.
NAMESPACE_CREATE_TABLE,NAMESPACE_DROP,NAMESPACE_LIST,NAMESPACE_READ_PROPERTIES. -
Table-level.
TABLE_READ_DATA,TABLE_WRITE_DATA,TABLE_MANAGE_SNAPSHOTS(rollback, expire),TABLE_MANAGE_ACCESS. -
Special.
SERVICE_ADMIN— the root-level "manage principals/roles" privilege; grant sparingly.
How grants become storage credentials.
-
1. Request arrives. Engine calls
GET /v1/warehouse/namespaces/sales/tables/orderswith a bearer token. - 2. Principal resolution. Polaris resolves the token to a principal; walks the principal_roles it belongs to; walks the catalog_roles those principal_roles are granted; collects all grants.
-
3. Grant evaluation. Polaris checks whether any grant provides
TABLE_READ_DATAonwarehouse.sales.orders. If not, 403. -
4. Scope computation. Polaris computes the union of storage prefixes the grants imply — for
TABLE_READ_DATAonwarehouse.sales.orders, the prefix iss3://acme-lake/warehouse/sales/orders/. -
5. Credential vending. Polaris
AssumeRolewith a session policy scoped to that prefix; returns STS session in the responseconfig.
Polaris RBAC vs Unity Catalog vs Glue — a comparison you must know.
- Polaris. Four-level (principal / principal_role / catalog_role / grant); privileges are catalog-neutral verbs; credential vending is built into the grant evaluation.
- Unity Catalog. Three-level (metastore / catalog / schema/table); privileges map directly to identity-token scope; UC-managed IAM handles credential lifecycle.
- Glue. IAM-passthrough (no catalog-level RBAC); Lake Formation adds a policy layer, but grants live in AWS IAM/LF, not in the catalog itself.
- The interview takeaway. Only Polaris and Unity Catalog have a first-class RBAC layer inside the catalog. Glue offloads to IAM. If the interviewer asks "which one gives me finer-grained control without touching IAM," the answer is Polaris or Unity.
Common interview probes on Polaris RBAC.
- "Walk me through the four RBAC levels." — required answer: principal, principal_role, catalog_role, grant.
- "Where do grants live?" — required answer: on catalog roles, not on principal roles.
- "How does a grant become a storage credential?" — grant -> STS scope -> vended session policy.
- "How would you give a team read on one namespace only?" — one catalog role with one namespace grant; attach it to the team's principal role.
Worked example — provisioning a new team with least-privilege access
Detailed explanation. A new "sales analytics" team joins the company; they need read on warehouse.sales.* but nothing else. The senior architect provisions them with three Polaris API calls (or three SQL-like DDL statements if using the SQL-adjacent management surface). Walk through the full provisioning — principals, roles, grants — and confirm the effective scope by loading a table as one of the new team members.
- Team. 5 analysts, one shared service account for their BI tool.
-
Scope. Read on
warehouse.sales.*; no writes; no other namespaces. - SSO. OIDC via Keycloak; principals map to Keycloak user IDs.
-
BI service account. OAuth2 client credentials; principal has no user, just a
client_id.
Question. Provision the team end-to-end and confirm the effective scope by simulating a table load.
Input.
| Object | Value |
|---|---|
| Principal role | sales_analyst |
| Catalog role |
sales_reader on catalog warehouse
|
| Grants |
TABLE_READ_DATA, NAMESPACE_READ_PROPERTIES on warehouse.sales
|
| Principals | 5 Keycloak users + 1 BI client |
Code.
# 1. Create the principal role (team-level grouping)
curl -X POST https://polaris.acme.internal/api/management/v1/principal-roles \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"name": "sales_analyst"}'
# 2. Create the catalog role (per-catalog permission bundle)
curl -X POST https://polaris.acme.internal/api/management/v1/catalogs/warehouse/catalog-roles \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"name": "sales_reader"}'
# 3. Attach grants to the catalog role
curl -X POST "https://polaris.acme.internal/api/management/v1/catalogs/warehouse/catalog-roles/sales_reader/grants" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{
"grant": {
"type": "namespace",
"namespace": ["sales"],
"privilege": "TABLE_READ_DATA"
}
}'
curl -X POST "https://polaris.acme.internal/api/management/v1/catalogs/warehouse/catalog-roles/sales_reader/grants" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{
"grant": {
"type": "namespace",
"namespace": ["sales"],
"privilege": "NAMESPACE_READ_PROPERTIES"
}
}'
# 4. Wire the catalog role to the principal role
curl -X PUT "https://polaris.acme.internal/api/management/v1/principal-roles/sales_analyst/catalog-roles/warehouse" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"catalogRole": {"name": "sales_reader"}}'
# 5. Add each Keycloak-mapped principal to the principal role
for user in alice bob carol dave eve; do
curl -X PUT "https://polaris.acme.internal/api/management/v1/principals/$user/principal-roles/sales_analyst" \
-H "Authorization: Bearer $ADMIN_TOKEN"
done
# 6. Create the BI service-account principal + attach
curl -X POST https://polaris.acme.internal/api/management/v1/principals \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"name": "bi-metabase", "type": "SERVICE"}'
curl -X PUT "https://polaris.acme.internal/api/management/v1/principals/bi-metabase/principal-roles/sales_analyst" \
-H "Authorization: Bearer $ADMIN_TOKEN"
# 7. Confirm the effective scope — simulate a table load as alice
import requests
def load_table_as(rest_url: str, client_id: str, client_secret: str,
catalog: str, ns: str, table: str) -> dict:
tok = requests.post(f"{rest_url}/v1/oauth/tokens",
data={"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "PRINCIPAL_ROLE:sales_analyst"}).json()
r = requests.get(f"{rest_url}/v1/{catalog}/namespaces/{ns}/tables/{table}",
headers={"Authorization": f"Bearer {tok['access_token']}"})
return {"status": r.status_code, "body": r.json() if r.ok else r.text}
# Success: sales.orders is within the granted namespace
print(load_table_as("https://polaris.acme.internal", "alice", "****", "warehouse", "sales", "orders"))
# {'status': 200, 'body': {'metadata': ..., 'config': {...STS...}}}
# Failure: finance.gl_entries is not in the granted namespace
print(load_table_as("https://polaris.acme.internal", "alice", "****", "warehouse", "finance", "gl_entries"))
# {'status': 403, 'body': '{"error":{"message":"Forbidden: TABLE_READ_DATA required on warehouse.finance.gl_entries"}}'}
Step-by-step explanation.
- The principal role (
sales_analyst) is created first — it holds no grants itself, only a team identity. Principal roles are the layer that maps to organisational groupings; they exist independently of any catalog. - The catalog role (
sales_reader) is created inside catalogwarehouse— it is scoped to this catalog and cannot be granted to anything outside it. Grants are attached here, not to the principal role. - Two grants are attached:
TABLE_READ_DATA(data reads) andNAMESPACE_READ_PROPERTIES(list tables, read schemas). Without the second, an engine could load a specific table but not enumerate what tables exist. Both are needed for a functional read experience. - The wire-up (
PUT /principal-roles/sales_analyst/catalog-roles/warehouse) links the principal role to the catalog role. This is the join that turns team identity into per-catalog capability. - The confirmation is a live table load — Alice can read
warehouse.sales.orders(in scope) but 403s onwarehouse.finance.gl_entries(out of scope). The 403 comes from the RBAC layer, before Polaris even consults storage; the STSAssumeRolenever happens.
Output.
| Principal | Action | Result | Vended scope |
|---|---|---|---|
| alice | LOAD warehouse.sales.orders
|
200 |
s3://acme-lake/warehouse/sales/orders/* (read-only) |
| alice | LOAD warehouse.finance.gl_entries
|
403 | (no STS vended) |
| bi-metabase | LOAD warehouse.sales.customers
|
200 | s3://acme-lake/warehouse/sales/customers/* |
| alice | COMMIT warehouse.sales.orders
|
403 | (no TABLE_WRITE_DATA) |
Rule of thumb. Provision teams with one principal role per team, one catalog role per (team, catalog) pair, and grants on catalog roles at the namespace level rather than the table level. This keeps the RBAC surface bounded even at hundreds of teams and thousands of tables.
Worked example — auditing an "unexpectedly permitted" access
Detailed explanation. A junior engineer reports that a colleague can read a table they shouldn't. The senior architect's playbook: walk the four RBAC levels backwards from the token, dump every effective grant, and pinpoint which grant (or inherited grant) allowed the access. This is a common on-call task and Polaris ships the endpoints to make it a 5-minute investigation rather than a 5-day one.
-
The report. "Bob (in finance_team) can
SELECT * FROM warehouse.sales.orders— should be denied." - The playbook. Get Bob's principal_roles, get their catalog_roles, get grants; find the one that allows.
- The fix. Remove the offending grant (or, if a role has been over-granted, split the role into a narrower one).
Question. Walk the four RBAC levels backwards from Bob's principal and identify the grant that permits the unexpected read.
Input.
| Level | Question |
|---|---|
| Principal | Which principal_roles does Bob belong to? |
| Principal role | Which catalog_roles are those principal_roles granted? |
| Catalog role | Which grants are attached? |
| Grant | Which grant matches TABLE_READ_DATA on warehouse.sales.orders? |
Code.
# 1. Bob's principal roles
curl -X GET "https://polaris.acme.internal/api/management/v1/principals/bob/principal-roles" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq
# -> ["finance_analyst", "all_hands_reader"]
# 2. Catalog roles granted to each principal role
for pr in finance_analyst all_hands_reader; do
echo "== $pr =="
curl -sX GET "https://polaris.acme.internal/api/management/v1/principal-roles/$pr/catalog-roles/warehouse" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq
done
# finance_analyst -> finance_reader
# all_hands_reader -> everyone_reader <-- suspicious
# 3. Grants attached to each catalog role
for cr in finance_reader everyone_reader; do
echo "== $cr =="
curl -sX GET "https://polaris.acme.internal/api/management/v1/catalogs/warehouse/catalog-roles/$cr/grants" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq
done
# finance_reader -> TABLE_READ_DATA on warehouse.finance
# everyone_reader -> TABLE_READ_DATA on warehouse <-- CATALOG-level grant! this is the bug
# 4. Automated "who can read this table?" reverse audit
import requests
BASE = "https://polaris.acme.internal/api/management/v1"
HDR = {"Authorization": f"Bearer {ADMIN_TOKEN}"}
def who_can(catalog: str, namespace_path: list[str], table: str, privilege: str) -> list[str]:
"""Return every principal that would be allowed the privilege on the target."""
principals = requests.get(f"{BASE}/principals", headers=HDR).json()
hits: list[str] = []
for p in principals:
prs = requests.get(f"{BASE}/principals/{p['name']}/principal-roles", headers=HDR).json()
for pr in prs:
crs = requests.get(f"{BASE}/principal-roles/{pr}/catalog-roles/{catalog}", headers=HDR).json()
for cr in crs:
grants = requests.get(f"{BASE}/catalogs/{catalog}/catalog-roles/{cr}/grants", headers=HDR).json()
for g in grants:
if g["privilege"] != privilege:
continue
if g["type"] == "catalog":
hits.append((p["name"], f"catalog-level via {cr}"))
elif g["type"] == "namespace" and namespace_path[:len(g["namespace"])] == g["namespace"]:
hits.append((p["name"], f"namespace {'.'.join(g['namespace'])} via {cr}"))
return hits
print(who_can("warehouse", ["sales"], "orders", "TABLE_READ_DATA"))
# [('alice', 'namespace sales via sales_reader'),
# ('bob', 'catalog-level via everyone_reader'), <-- unexpected
# ('carol', 'namespace sales via sales_reader'), ...]
Step-by-step explanation.
- Step 1 fetches Bob's principal roles — the entry point of the reverse walk. Bob turns out to belong to two: the expected
finance_analyst(correctly scoped) and an unexpectedall_hands_reader(added by an old provisioning script). - Step 2 walks each principal role to its catalog roles inside
warehouse.all_hands_readerpoints ateveryone_reader— a name that already smells wrong for a least-privilege setup. - Step 3 dumps grants on each catalog role.
finance_readercorrectly limits towarehouse.finance; buteveryone_readerhas a catalog-levelTABLE_READ_DATAgrant — which propagates to every table in the catalog includingwarehouse.sales.orders. That's the bug. - The
who_canscript generalises the audit: it enumerates every principal and every grant path, returning the principals that would be allowed for the target privilege. Running it as a scheduled job (weekly) catches "unexpectedly permitted" regressions before users report them. - The fix is a two-step: revoke the catalog-level grant from
everyone_reader, then either narrow the role to specific namespaces or remove it entirely. Never patch by removing Bob fromall_hands_reader— the underlying grant is still wrong.
Output.
| Investigation step | Finding |
|---|---|
| Bob's principal_roles |
finance_analyst, all_hands_reader
|
all_hands_reader catalog roles |
everyone_reader |
everyone_reader grants |
catalog-level TABLE_READ_DATA on warehouse
|
| Root cause | over-broad catalog-level grant, not a per-user misconfiguration |
| Fix | revoke catalog-level; scope to intended namespaces |
Rule of thumb. Every unexpected access is a walk of the four RBAC levels backwards from the principal. Ship the who_can reverse-audit script and run it weekly; it catches over-grants introduced by provisioning drift long before users notice.
Worked example — auditing token exchanges and vended credentials
Detailed explanation. Every OAuth2 token issuance and every credential vend is an audit event. Polaris logs both to a JSON audit log (also exportable to OpenTelemetry). The senior architect stitches the audit log with CloudTrail's AssumeRole events to produce a single row per "who touched what table when, with what STS session, and what did they do with it." Walk through building the join.
-
Polaris audit log. JSON lines with
event=OAUTH_TOKEN_ISSUEDorevent=CREDENTIAL_VENDED, principal, catalog, namespace, table, timestamp, session-name. -
AWS CloudTrail.
AssumeRoleevents with the samesession-nameand the actualsourceIPAddress,userAgent, and subsequent S3 API calls. -
The join key.
session-name(Polaris sets it aspolaris-<principal>-<uuid>).
Question. Design the audit-log-plus-CloudTrail join that produces a single-row-per-access audit report.
Input.
| Data source | Event | Join key |
|---|---|---|
| Polaris audit log | CREDENTIAL_VENDED | session_name |
| AWS CloudTrail | AssumeRole | requestParameters.roleSessionName |
| AWS CloudTrail | S3 API calls | userIdentity.sessionContext.sessionIssuer.arn + session name |
Code.
# Polaris audit config — emit JSON audit log
polaris:
audit:
enabled: true
format: JSON
sinks:
- type: file
path: /var/log/polaris/audit.jsonl
- type: opentelemetry
endpoint: otel-collector:4317
# Stitch Polaris audit log with CloudTrail into "one row per table access"
import json
from pathlib import Path
from collections import defaultdict
def build_access_report(polaris_audit: Path, cloudtrail_events: list[dict]) -> list[dict]:
# 1. Index Polaris vend events by session_name
vends = {}
with polaris_audit.open() as f:
for line in f:
e = json.loads(line)
if e.get("event") == "CREDENTIAL_VENDED":
vends[e["session_name"]] = e
# 2. Index CloudTrail S3 calls by session_name
s3_calls = defaultdict(list)
for ct in cloudtrail_events:
if ct["eventSource"] != "s3.amazonaws.com":
continue
sc = ct["userIdentity"].get("sessionContext", {})
sn = sc.get("sessionIssuer", {}).get("userName", "") # rough proxy
if sn.startswith("polaris"):
s3_calls[sn].append({
"op": ct["eventName"],
"obj": ct.get("resources", [{}])[0].get("ARN", ""),
"ts": ct["eventTime"],
})
# 3. Join and emit one row per table access
report = []
for sn, vend in vends.items():
report.append({
"principal": vend["principal"],
"catalog": vend["catalog"],
"namespace": vend["namespace"],
"table": vend["table"],
"vended_at": vend["ts"],
"s3_calls": s3_calls.get(sn, []),
"privilege": vend["privilege"],
"session": sn,
})
return report
-- Turn the joined report into a warehouse table for BI dashboards
CREATE TABLE audit.table_access (
principal TEXT,
catalog TEXT,
namespace TEXT,
table_name TEXT,
vended_at TIMESTAMPTZ,
s3_get_count INT,
s3_put_count INT,
session TEXT
);
-- Common queries
SELECT principal, namespace || '.' || table_name AS obj, count(*)
FROM audit.table_access
WHERE vended_at >= now() - INTERVAL '7 days'
GROUP BY 1, 2
ORDER BY 3 DESC
LIMIT 20;
Step-by-step explanation.
- Polaris audit log emits one JSON line per credential vend with the session name; the session name is Polaris's choice at
AssumeRoletime and is the join key you own end-to-end. - CloudTrail records the AWS-side
AssumeRoleand every subsequent S3 API call using the resulting session credentials; theuserIdentity.sessionContext.sessionIssuer.userName(or similar CloudTrail field) carries the session name back. - The join stitches "Polaris said Alice needed this token" (source) with "AWS observed these S3 GETs using that token" (verification). Any mismatch — a token vended but no S3 activity, or S3 activity from a session Polaris didn't vend — is a security signal worth investigating.
- Loading the joined report into a warehouse table lets the security team run standard SQL queries: top-N table access by principal, unusual read patterns, credential vends outside business hours, etc. This turns the audit stream into a first-class data product.
- The most valuable operational query is "who read this sensitive table last week" — a common answer to compliance requests. With the audit report in place, this becomes a 5-second SQL query rather than a 5-day investigation.
Output.
| Field | Example |
|---|---|
| principal | alice |
| catalog | warehouse |
| namespace | sales |
| table_name | orders |
| vended_at | 2026-07-15 14:23:11 UTC |
| s3_get_count | 14 |
| s3_put_count | 0 |
| session | polaris-alice-9f3c... |
Rule of thumb. Audit is not optional. Turn on Polaris audit logging on day one, stitch it with CloudTrail via the session-name join key, and load the joined stream into a warehouse table for BI. Every "who touched this table" question then becomes a SQL query, not a runbook.
Senior interview question on Polaris RBAC
A senior interviewer might ask: "Design the RBAC model for a Polaris deployment with 40 teams, 8000 tables, four sensitivity tiers (public, internal, confidential, restricted), and two service-account patterns (per-team ETL, per-team BI). Show the principal / role / grant layout, how tier metadata drives grant automation, and how you'd detect and alert on grant drift."
Solution Using a role-per-team, tier-tagged-namespaces, and a nightly grant-drift reconciler
-- 1. Role naming convention
-- principal_role: <team>_analyst | <team>_etl | <team>_bi
-- catalog_role: <team>_reader | <team>_writer | <team>_bi_reader
-- 2. Tier as a namespace property (drives automation)
-- warehouse.sales (tier=internal)
-- warehouse.sales.pii (tier=confidential)
-- warehouse.finance.regulated (tier=restricted)
-- 3. Base grants — read on tier<=internal for every analyst
CREATE CATALOG_ROLE tier_internal_reader ON CATALOG warehouse;
GRANT TABLE_READ_DATA, NAMESPACE_READ_PROPERTIES
ON NAMESPACE warehouse
TO CATALOG_ROLE tier_internal_reader
WHERE namespace_property('tier') <= 'internal';
-- 4. Extra role for restricted access (per-user manual grant)
CREATE CATALOG_ROLE restricted_reader ON CATALOG warehouse;
GRANT TABLE_READ_DATA
ON NAMESPACE warehouse.finance.regulated
TO CATALOG_ROLE restricted_reader;
# 5. Nightly grant-drift reconciler
# Source of truth: git-committed YAML files; declarative and reviewed.
# Actuator: diff YAML vs Polaris API state; PR any drift.
import yaml
from pathlib import Path
def load_desired_state(path: Path) -> dict:
"""Load the desired RBAC layout from a YAML file per team."""
state = {"principals": {}, "principal_roles": {}, "catalog_roles": {}, "grants": []}
for f in path.glob("*.yaml"):
team = yaml.safe_load(f.read_text())
state["principals"].update({p: {"team": team["name"]} for p in team["members"]})
state["principal_roles"][f"{team['name']}_analyst"] = team["members"]
for cr in team["catalog_roles"]:
state["catalog_roles"][cr["name"]] = cr
for g in cr["grants"]:
state["grants"].append({"catalog_role": cr["name"], **g})
return state
def actual_state_from_polaris() -> dict:
# (identical shape; fetched from the Polaris management API)
...
def diff_and_report(desired: dict, actual: dict) -> list[str]:
drift = []
for k in ("principals", "principal_roles", "catalog_roles"):
for name in set(desired[k]) - set(actual[k]):
drift.append(f"MISSING in Polaris: {k} {name}")
for name in set(actual[k]) - set(desired[k]):
drift.append(f"EXTRA in Polaris: {k} {name}")
return drift
# 6. Alerting on grant drift
# The reconciler emits a Prometheus counter `polaris_rbac_drift_total{kind="..."}`.
# Alert: increase(polaris_rbac_drift_total[24h]) > 0
Step-by-step trace.
| Layer | Component | Purpose |
|---|---|---|
| Principals | SSO-mapped (users) + client-credentials (services) | one identity model |
| Principal roles | one per (team, function): sales_analyst, sales_etl, sales_bi
|
organisational grouping |
| Catalog roles | one per (team, capability): sales_reader, sales_writer, sales_bi_reader
|
permission bundle |
| Grants | attached to catalog roles; keyed by namespace + tier | least privilege |
| Tier metadata | namespace_property('tier') |
drives automated grants |
| Restricted access | separate catalog role; per-user attachment | audit-worthy exception path |
| Grant-drift reconciler | YAML source-of-truth; nightly diff | prevents accidental over-grant |
After the rollout, every team owns their principal role and catalog roles in git-committed YAML; the reconciler enforces the desired state nightly; over-grants get a PR and a Slack alert rather than staying in production for six months; the tier-based automation means adding a new namespace tagged confidential immediately excludes the base analyst role without any manual grant edit.
Output:
| Metric | Value |
|---|---|
| Total principals | ~600 (SSO users) + 80 service accounts |
| Total principal roles | ~120 (3 per team) |
| Total catalog roles | ~160 (4 per team on average) |
| Total grants | ~800 (mostly namespace-level) |
| Grant-drift alerts per week | 0-2 (each investigated within 24 h) |
| Restricted-tier accesses per month | ~15 (all audited) |
Why this works — concept by concept:
- Role per team, not per user — principal roles are organisational; per-user roles do not scale past ~50 users. Membership is the layer that changes when someone joins or leaves; the role stays.
-
Namespace tiers drive grant automation — tagging namespaces with
tier=internal|confidential|restrictedlets grants be expressed conditionally, so adding a new namespace inherits the appropriate policy without a manual grant edit. - Restricted access is a separate role — never mix "everyone can read" with "only some can read restricted"; separate catalog roles keep audit trails clean and revocation cheap.
- YAML source-of-truth + nightly reconciler — the RBAC state lives in git, reviewed via PR, applied by an actuator. Drift is detected and either auto-corrected or alerted; humans never make catalog-role changes by hand in production.
- Cost — one Polaris deployment (unchanged), one YAML repo (git), one reconciler job (~5 min nightly), Prometheus alerting on drift. The eliminated cost is the O(hours) audit of "who has access to what" and the O(days) investigation when an over-grant is noticed. Net O(1) per grant change rather than O(engineers x runbook).
Design
Topic — design
Design problems on lakehouse RBAC systems
4. Multi-engine access — Trino, Spark, Flink, Snowflake
Every Iceberg-native engine speaks the same REST spec, so the connection string is essentially the same four settings plus OAuth2
The mental model in one line: every Iceberg-native engine — Trino, Spark, Flink, Snowflake, Dremio, Athena — implements the client side of the Iceberg REST Catalog Spec, which means "connect this engine to Polaris" is the same four settings across all of them (REST endpoint URL, catalog name, OAuth2 client_id, OAuth2 client_secret), the wire protocol is identical, and the credential vending happens transparently so the engine's data-file readers work exactly like they would against any S3 bucket — the shared catalog is what makes concurrent writes from different engines actually safe because Iceberg's optimistic-lock commit protocol requires a single arbiter for the CAS on snapshot_id. Every senior architect must be able to configure at least three of these engines from memory and explain the failure modes.
The universal Polaris-connection settings across engines.
-
REST endpoint URL.
https://polaris.acme.internal— the base URL of the Polaris server. The/v1/{prefix}path is derived from the catalog name. -
Catalog name (prefix).
warehouse— the Polaris catalog to target. This becomes the{prefix}in every REST path. -
OAuth2 client_id. The engine's client identifier registered with Polaris (
spark-etl,trino-cluster-1, etc.). - OAuth2 client_secret. Stored in the engine's secrets manager; never in config files.
- (Optional) STS token cache directory. Some engines cache the vended STS credentials for the token lifetime; Spark/Flink typically re-request per session.
How the commit protocol keeps concurrent writers honest.
-
Read. The writer reads the current
snapshot_idfor the table viaGET /v1/{prefix}/namespaces/{ns}/tables/{table}. - Write. The writer stages new data files + manifest files under the table's storage prefix.
-
Commit. The writer POSTs a commit request with
expected-current-snapshot-id = <the one it read>and the new snapshot proposal. -
CAS. Polaris compares
expected-current-snapshot-idagainst the stored value; on match, advances the pointer; on mismatch, returns 409 Conflict. - Retry. The engine's Iceberg client catches the 409, re-reads the current snapshot, and retries the commit against the new base. Most Iceberg operations (append, overwrite, delete) are commutative and retry safely.
Latency profile across engines (steady state).
- Trino. ~10-50 ms per REST call; table load ~200 ms including STS vend; SELECT latency dominated by S3 GETs, not by Polaris.
- Spark 3.5+. ~50-100 ms per REST call (JVM startup adds one-off cost); table load ~300 ms; batch job overhead is minutes so Polaris is noise.
- Flink 1.19+. ~50-100 ms per REST call; streaming CDC sinks re-vend credentials on token expiry (~1 h); Polaris is out of the hot path once the job is running.
- Snowflake. ~100-200 ms per REST call (network hop from Snowflake VPC to Polaris); Snowflake External Volumes cache credentials for the query lifetime.
Common interview probes on multi-engine access.
- "How would you connect Trino to Polaris?" — required answer: four settings + OAuth2 client credentials.
- "Two Spark jobs try to append to the same table simultaneously — what happens?" — Iceberg optimistic-lock CAS; one succeeds, one 409s and retries against the new snapshot.
- "The engine's STS token expired mid-query — what happens?" — the engine's Iceberg client re-requests via
GET /tableand refreshes the STS; long-running queries need the engine to handle refresh. - "Snowflake and Trino both wrote to the same Iceberg table — how do we reconcile?" — they can't reconcile after the fact; the shared catalog is what prevents the divergence in the first place.
Worked example — configuring Trino to read/write via Polaris
Detailed explanation. Trino is the fastest engine to wire up because its Iceberg connector already ships with REST catalog support. The catalog config lives in etc/catalog/warehouse.properties. Once wired, SHOW SCHEMAS FROM warehouse returns the Polaris namespaces and SELECT * FROM warehouse.sales.orders works transparently. Walk through the config and confirm end-to-end.
- Trino version. 458 (any 4xx works; earlier had some REST-catalog edge cases).
-
Config file.
etc/catalog/warehouse.propertieson every coordinator/worker. - Auth. OAuth2 client_credentials to Polaris; STS creds vended per table load.
Question. Configure Trino to talk to Polaris and confirm with a query.
Input.
| Setting | Value |
|---|---|
| catalog | warehouse |
| REST endpoint | https://polaris.acme.internal |
| Auth | OAuth2 client_credentials |
| Client ID | trino-cluster-1 |
Code.
# etc/catalog/warehouse.properties (Trino)
connector.name=iceberg
iceberg.catalog.type=rest
iceberg.rest-catalog.uri=https://polaris.acme.internal
iceberg.rest-catalog.warehouse=warehouse
iceberg.rest-catalog.security=OAUTH2
iceberg.rest-catalog.oauth2.credential=trino-cluster-1:${env:POLARIS_TRINO_SECRET}
iceberg.rest-catalog.oauth2.scope=PRINCIPAL_ROLE:trino_reader
iceberg.rest-catalog.oauth2.token-refresh-enabled=true
# Storage side — Trino uses the STS creds Polaris returns; no static keys needed
iceberg.rest-catalog.vended-credentials-enabled=true
# Optional: local caching so Trino doesn't call Polaris on every subquery
iceberg.rest-catalog.session-token-cache-size=1000
-- Verify from a Trino client
SHOW CATALOGS;
-- warehouse, system
SHOW SCHEMAS FROM warehouse;
-- sales, finance, marketing, information_schema
SHOW TABLES FROM warehouse.sales;
-- orders, customers, order_items
SELECT count(*) FROM warehouse.sales.orders;
-- 1234567
DESCRIBE warehouse.sales.orders;
-- id bigint
-- customer_id bigint
-- total_cents bigint
-- status varchar
-- created_at timestamp(6) with time zone
-- updated_at timestamp(6) with time zone
Step-by-step explanation.
- Trino's
iceberg.catalog.type=restswitches the connector into REST-catalog mode; without this it defaults to HMS or Glue and none of the Polaris features work. This one setting is the whole switch. -
iceberg.rest-catalog.warehouse=warehousesets the{prefix}in every REST call — Trino now issuesGET /v1/warehouse/namespaces/.... Change this to target a different Polaris catalog on the same server. -
oauth2.credentialcombines client_id and client_secret inid:secretform for the client_credentials grant; storing the secret via env var (${env:POLARIS_TRINO_SECRET}) keeps it out of the file.oauth2.scoperequests the specific principal role, so the token is minted with the intended team scope. -
vended-credentials-enabled=trueis the setting that tells Trino to use the STS credentials Polaris returns in the table-load response, rather than requiring static AWS keys. Without this Trino would ignore the vended creds and try to use its own S3 client — which typically has no S3 access. -
session-token-cache-size=1000keeps the vended STS in memory for the token lifetime so repeated queries on the same table don't re-vend; the token cache is bounded, so no memory leak.token-refresh-enabled=truetransparently renews the OAuth2 bearer.
Output.
| Query | Behind the scenes |
|---|---|
SHOW CATALOGS |
local Trino config lookup (no Polaris call) |
SHOW SCHEMAS FROM warehouse |
GET /v1/warehouse/namespaces |
SHOW TABLES FROM warehouse.sales |
GET /v1/warehouse/namespaces/sales/tables |
SELECT count(*) FROM warehouse.sales.orders |
GET /v1/warehouse/namespaces/sales/tables/orders + direct S3 reads |
Rule of thumb. Trino connects to Polaris with five settings (connector.name, catalog.type, rest-catalog.uri, rest-catalog.warehouse, rest-catalog.oauth2.credential) plus vended-credentials-enabled=true. Anything more is optional tuning; anything less won't work.
Worked example — Spark 3.5 batch write against Polaris
Detailed explanation. Spark's REST catalog support is production-ready in the iceberg-spark-runtime-3.5_2.12 package. The catalog config lives in Spark session settings (or spark-defaults.conf). A Spark batch job writing to warehouse.sales.daily_summary connects via OAuth2, gets an STS session per table load, writes new Parquet + manifest files under s3://acme-lake/warehouse/sales/daily_summary/, then commits via the CAS. Walk through the full session.
-
Spark version. 3.5.1 with
iceberg-spark-runtime-3.5_2.12:1.5.2. -
Job.
INSERT INTO warehouse.sales.daily_summary SELECT ... FROM warehouse.sales.orders. -
Auth. OAuth2 client_credentials; principal role
spark_etl.
Question. Configure Spark, run an INSERT job, and inspect the resulting Iceberg snapshot chain.
Input.
| Setting | Value |
|---|---|
| Spark version | 3.5.1 |
| Iceberg runtime | iceberg-spark-runtime-3.5_2.12:1.5.2 |
| Catalog | warehouse |
| Principal role | spark_etl |
Code.
# Spark session config
from pyspark.sql import SparkSession
spark = (
SparkSession.builder
.appName("daily-summary")
.config("spark.jars.packages",
"org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.5.2")
.config("spark.sql.catalog.warehouse", "org.apache.iceberg.spark.SparkCatalog")
.config("spark.sql.catalog.warehouse.catalog-impl", "org.apache.iceberg.rest.RESTCatalog")
.config("spark.sql.catalog.warehouse.uri", "https://polaris.acme.internal")
.config("spark.sql.catalog.warehouse.warehouse", "warehouse")
.config("spark.sql.catalog.warehouse.credential", "spark-etl:{}".format(os.environ["POLARIS_SPARK_SECRET"]))
.config("spark.sql.catalog.warehouse.scope", "PRINCIPAL_ROLE:spark_etl")
.config("spark.sql.catalog.warehouse.header.X-Iceberg-Access-Delegation", "vended-credentials")
.config("spark.sql.defaultCatalog", "warehouse")
.getOrCreate()
)
# Batch job — INSERT INTO SELECT
spark.sql("""
INSERT INTO warehouse.sales.daily_summary
SELECT DATE_TRUNC('day', created_at) AS day,
status,
count(*) AS orders,
sum(total_cents) AS revenue_cents
FROM warehouse.sales.orders
WHERE created_at >= current_date() - INTERVAL 1 DAY
GROUP BY 1, 2
""")
-- Inspect the snapshot chain (Iceberg metadata SQL)
SELECT snapshot_id,
parent_id,
operation,
summary['added-data-files'] AS added_files,
summary['added-records'] AS added_records,
committed_at
FROM warehouse.sales.daily_summary.snapshots
ORDER BY committed_at DESC
LIMIT 5;
-- snapshot_id | parent_id | op | added_files | added_records | committed_at
-- 8321477892312389 | 8321477011232111 | append | 3 | 4213 | 2026-07-15 04:12:47
-- 8321477011232111 | 8321476334221101 | append | 3 | 4188 | 2026-07-14 04:12:29
-- 8321476334221101 | ... | append | 3 | 4201 | 2026-07-13 04:12:31
Step-by-step explanation.
- Spark's
SparkCatalogis the top-level catalog implementation;catalog-impl=RESTCatalogtells it to speak the Iceberg REST spec. Theuri+warehousesettings are the standard four; thecredential+scopesettings supply OAuth2 client credentials. - The
X-Iceberg-Access-Delegation: vended-credentialsheader is the magic line that tells Polaris "please vend STS credentials in the response, I will use them for S3." Without this header, Polaris returns table metadata but no credentials; Spark then fails on S3 read because it has no keys. - The INSERT INTO SELECT triggers: (a) source table load (Polaris returns metadata + read STS), (b) source data read (direct S3 with the read STS), (c) target table load (Polaris returns metadata + write STS), (d) staged Parquet + manifest write (direct S3 with the write STS), (e) commit request (Polaris CAS on snapshot_id).
- Iceberg's snapshot chain grows by one snapshot per commit.
.snapshotsmetadata table is the source of truth for the write history; every commit records added/removed files and the parent snapshot, making time-travel and audit trivial. - The optimistic-lock CAS shows up as a 409 Conflict from Polaris if two Spark jobs commit simultaneously; the Iceberg client catches the 409, re-reads the current snapshot, replays its commit against the new base, and re-CAS. For appends this is transparent; for overwrites it may require re-computing the write.
Output.
| Phase | Duration | Notes |
|---|---|---|
| Session start + OAuth | ~2 s | one-time per driver |
| Source table load | ~300 ms | includes STS vend |
| Source data read | job-dependent | direct S3 |
| Target table load | ~300 ms | includes write STS vend |
| Staged Parquet write | job-dependent | direct S3 |
| Commit (CAS) | ~50-200 ms | 409 on conflict → retry |
| Snapshot advance | 1 per commit | visible in .snapshots table |
Rule of thumb. For Spark + Polaris, use SparkCatalog with catalog-impl=RESTCatalog, set the four standard settings, and always set the X-Iceberg-Access-Delegation: vended-credentials header. Missing the header is the #1 misconfiguration that shows up as "Spark says my table exists but can't read the data."
Worked example — Flink streaming CDC sink writing to Polaris
Detailed explanation. Flink 1.19+ ships an Iceberg sink that supports the REST catalog. A common streaming CDC pattern: Debezium reads from Postgres, publishes to Kafka; a Flink job consumes Kafka, transforms, and sinks to an Iceberg table via Polaris. The tricky bit is that Flink jobs run for weeks — OAuth tokens expire (1 h), STS credentials expire (1 h), and the job must transparently refresh both without dropping data. Walk through the config and the refresh contract.
-
Flink version. 1.19 with
flink-sql-connector-iceberg-1.5.2. -
Source. Kafka topic
orders-cdcpopulated by Debezium. -
Sink. Iceberg table
warehouse.streaming.orders_stream. - Checkpoint interval. 60 s (also the Iceberg commit interval).
Question. Configure Flink to sink into Polaris and describe how token / credential refresh happens.
Input.
| Component | Value |
|---|---|
| Kafka topic | orders-cdc |
| Iceberg table | warehouse.streaming.orders_stream |
| Checkpoint interval | 60 s |
| OAuth token TTL | 3600 s |
| STS session TTL | 3600 s |
Code.
-- Flink SQL — define catalog
CREATE CATALOG warehouse WITH (
'type' = 'iceberg',
'catalog-impl' = 'org.apache.iceberg.rest.RESTCatalog',
'uri' = 'https://polaris.acme.internal',
'warehouse' = 'warehouse',
'credential' = 'flink-cdc:REDACTED',
'scope' = 'PRINCIPAL_ROLE:flink_writer',
'header.X-Iceberg-Access-Delegation' = 'vended-credentials'
);
USE CATALOG warehouse;
-- Kafka source
CREATE TABLE orders_source (
id BIGINT,
customer_id BIGINT,
total_cents BIGINT,
status STRING,
event_time TIMESTAMP(3),
WATERMARK FOR event_time AS event_time - INTERVAL '10' SECOND
) WITH (
'connector' = 'kafka',
'topic' = 'orders-cdc',
'properties.bootstrap.servers' = 'kafka:9092',
'format' = 'json',
'scan.startup.mode' = 'group-offsets',
'properties.group.id' = 'flink-orders-sink'
);
-- Iceberg sink (auto-created in Polaris; namespace pre-created)
CREATE TABLE IF NOT EXISTS streaming.orders_stream (
id BIGINT,
customer_id BIGINT,
total_cents BIGINT,
status STRING,
event_time TIMESTAMP(3)
) PARTITIONED BY (DATE(event_time));
-- Streaming INSERT
INSERT INTO streaming.orders_stream
SELECT id, customer_id, total_cents, status, event_time
FROM orders_source;
# flink-conf.yaml — checkpointing and refresh behaviour
execution.checkpointing.interval: 60s
execution.checkpointing.mode: EXACTLY_ONCE
# Iceberg sink options (applied automatically at commit time)
# The Iceberg-Flink integration re-vends credentials via the REST client
# whenever the current STS session is within 5 min of expiry, and re-vends
# the OAuth2 bearer via the standard refresh grant whenever it is within
# 5 min of expiry. Both happen without stopping the job.
Step-by-step explanation.
- Flink's
CREATE CATALOGmirrors Spark's — same four core settings, sameX-Iceberg-Access-Delegationheader. Thecatalog-impl=RESTCatalogis what turns on the REST-catalog code path. - The Kafka source emits ordered records with an event-time watermark; Flink's checkpoint mechanism aligns these into consistent groups. Every 60 seconds, Flink triggers a checkpoint, which for the Iceberg sink means "commit a new snapshot to Polaris."
- The Iceberg sink writes Parquet files continuously to
s3://acme-lake/warehouse/streaming/orders_stream/data/, then on checkpoint sends a commit to Polaris. The commit is an atomic snapshot advance — either all files land in the new snapshot or none do. - Token/credential refresh is the streaming-specific concern. The Iceberg REST client tracks the OAuth2 bearer expiry and the STS session expiry independently; when either is within 5 min of expiry, it re-negotiates. The Flink job never pauses; the refresh is background.
- On engine restart (Flink task failure), the last checkpoint's committed snapshot is the durable state; Flink replays Kafka from the last checkpoint's offset and re-writes to Iceberg. Iceberg's optimistic-lock CAS handles the "double-committed" case gracefully because the replayed commit refers to the same source data.
Output.
| Metric | Value |
|---|---|
| End-to-end latency (Kafka -> Iceberg query) | ~60 s (checkpoint interval) |
| Snapshot commit rate | 1 per minute |
| Vended STS refresh cadence | ~55 min (5-min pre-expiry) |
| OAuth2 refresh cadence | ~55 min |
| Files per commit | 5-50 (depends on throughput) |
| Recovery from Flink failure | resume from last committed snapshot |
Rule of thumb. For Flink + Polaris, use the same catalog settings as Spark plus rely on the Iceberg-Flink integration's automatic token/credential refresh. Set the checkpoint interval to match the desired commit cadence — 60 s is a good default; go lower for lower latency but expect more small-file overhead.
Senior interview question on multi-engine access
A senior interviewer might ask: "You have a Trino ad-hoc SQL cluster, a Spark batch ETL cluster, and a Flink streaming job all writing to the same Iceberg table warehouse.sales.orders via Polaris. During a spike, you see a burst of 409 Conflict errors on Spark commits. Walk me through the Iceberg commit protocol, explain what a 409 means here, and design the retry policy that keeps all three engines correct without livelock."
Solution Using the optimistic-lock CAS on snapshot_id, per-engine retry with exponential backoff, and a commit-rate observability contract
# 1. What Iceberg's commit looks like on the wire (illustrative)
# Spark's Iceberg client wraps this; showing the raw HTTP for clarity.
import requests
def commit_snapshot(rest_url, catalog, ns, table, token, expected_snapshot_id,
new_snapshot):
body = {
"requirements": [{"type": "assert-ref-snapshot-id",
"ref": "main",
"snapshot-id": expected_snapshot_id}],
"updates": [{"action": "add-snapshot", "snapshot": new_snapshot},
{"action": "set-current-snapshot",
"snapshot-id": new_snapshot["snapshot-id"]}]
}
r = requests.post(
f"{rest_url}/v1/{catalog}/namespaces/{ns}/tables/{table}",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json=body,
)
if r.status_code == 409:
raise ConflictException(r.json())
r.raise_for_status()
return r.json()
# 2. Retry policy — exponential backoff with jitter, capped at 8 attempts
import random, time
def commit_with_retry(commit_fn, load_fn, max_attempts=8, base_ms=100):
"""Retry an Iceberg commit with exponential backoff on 409 conflict."""
for attempt in range(1, max_attempts + 1):
# Re-read the current snapshot every attempt
current = load_fn() # GET /table -> current snapshot_id
try:
return commit_fn(current["current-snapshot-id"])
except ConflictException:
if attempt == max_attempts:
raise
delay = base_ms * (2 ** (attempt - 1))
delay = delay + random.uniform(0, delay * 0.25) # jitter
time.sleep(delay / 1000.0)
raise RuntimeError("unreachable")
# 3. Commit-rate observability contract — every engine emits these metrics
metrics:
iceberg_commit_attempts_total: counter
iceberg_commit_conflicts_total: counter
iceberg_commit_success_total: counter
iceberg_commit_latency_seconds: histogram
# Prometheus alert: conflict rate > 5% for 10 min
# expr: sum(rate(iceberg_commit_conflicts_total[5m]))
# / sum(rate(iceberg_commit_attempts_total[5m])) > 0.05
Step-by-step trace.
| Concern | Answer | Reasoning |
|---|---|---|
| Commit protocol | CAS on snapshot_id via REST POST |
shared arbiter (Polaris) |
| 409 semantics | another writer committed since we read | Iceberg spec-defined |
| Retry strategy | re-read snapshot, exponential backoff, jitter | avoid livelock |
| Backoff caps | 8 attempts, 12.8 s max delay | bound blast radius |
| Commutativity | appends are always commutative; overwrites may not be | job-type dependent |
| Alerting | conflict rate > 5% sustained | signals contention hotspot |
| Fix strategies | serialise writers; partition per writer; reduce commit frequency | job design changes |
The trace shows a burst of 409 conflicts is expected under multi-writer contention on a single table — it does not indicate corruption or a Polaris fault. The retry policy handles it transparently; the alert fires when the rate stays high enough to hurt throughput. The long-term fix is usually to (a) partition writes so each writer targets a disjoint set of files, (b) reduce the commit frequency of the noisiest writer, or (c) serialise writers with a distributed lock outside Iceberg.
Output:
| Metric | Value |
|---|---|
| Commit attempts per Spark job | 1-8 (usually 1) |
| Conflict rate (steady state) | < 1% |
| Conflict rate (during spike) | 15-25% |
| Retry backoff (final attempt) | ~12.8 s |
| Job-level failure rate after 8 retries | ~0.01% |
| Time to recover on hotspot | minutes (with partitioned writers) |
Why this works — concept by concept:
-
Iceberg optimistic-lock CAS — the commit request carries
expected-current-snapshot-id; Polaris returns 409 on mismatch. This turns concurrent writes into a race where one wins and losers retry — no locks, no coordination overhead in the happy path. - Shared arbiter (Polaris) — the CAS only works if all writers CAS against the same catalog. Without a shared catalog, two writers can each think they succeeded; the shared Polaris is what makes multi-engine writes safe.
- Retry with jitter — pure exponential backoff creates thundering-herd retries at synchronised delays; adding 0-25% jitter spreads retries out and breaks the herd. Cap attempts to bound recovery time.
- Commutativity of appends — Iceberg appends are commutative — replaying them against a new base snapshot always produces the same result. Overwrites may not be commutative; those jobs need to re-check semantics on retry (or serialise).
- Cost — one Polaris deployment (shared arbiter), per-engine Iceberg client (no changes), one Prometheus metric family per engine. The eliminated cost is the O(days) investigation of "why did our writes overwrite each other" — the CAS makes that impossible. Net O(retries) per commit rather than O(engineers) per outage.
Streaming
Topic — streaming
Streaming CDC sinks and Iceberg commit problems
5. Migrating to Polaris + interview signals
Three migration paths — Glue federation, HMS shim, UC federation — let you move without rewriting a single engine
The mental model in one line: migrating to Polaris is almost never a big-bang cutover; you pick one of three federation paths depending on the source catalog — SDK proxy / EXTERNAL federation from AWS Glue, the polaris-catalog-hms wire-protocol shim for Hive Metastore, or Unity Catalog federation over the UC federation API — and each path lets Polaris expose the same tables to Iceberg-REST clients while the legacy catalog remains the source of truth, so you migrate engines one at a time (moving to REST + Polaris) rather than migrating tables one at a time (moving out of the legacy catalog), which is the operational reality every senior architect must design for. Every senior interview probes this because the "we start greenfield" answer is almost never true in practice.
The three canonical migration paths.
-
From AWS Glue. Polaris supports Glue as an
EXTERNALcatalog viacatalog-impl=org.apache.iceberg.aws.glue.GlueCatalog. Polaris speaks Iceberg REST on the front side and Glue SDK on the back side. Tables stay in Glue; engines move to REST at their own pace. -
From Hive Metastore. The
polaris-catalog-hmsshim (or the Iceberg-nativeHiveCatalogwithEXTERNALmode in Polaris) exposes HMS-managed tables via Iceberg REST. HMS remains the metadata store; Polaris translates every REST call into HMS Thrift RPCs. - From Unity Catalog. Polaris supports Unity Catalog federation over the UC federation API (2024+); Polaris pulls table metadata from UC and re-exposes it under Iceberg REST. Databricks writes stay in Unity; other engines read via Polaris.
The engine-by-engine cutover recipe.
- Phase 1. Deploy Polaris pointed at your existing catalog via federation. All engines still use the legacy catalog directly.
- Phase 2. Reconfigure one non-critical engine (e.g. an ad-hoc Trino cluster) to use Polaris REST. Verify parity — same tables, same schemas, same query results.
- Phase 3. Migrate engines one at a time (Spark ETL, Snowflake external volumes, Flink jobs). Each cutover is one config change per engine.
- Phase 4. Deprecate the legacy catalog's client SDKs; leave the catalog itself running as the metadata source of truth (federation continues).
- Phase 5 (optional). Move to Polaris as source of truth. Migrate table metadata out of the legacy catalog into Polaris's persistence store; switch federation off; decommission the legacy catalog.
Interview signals on migration.
- Do you name federation as the migration bridge, not big-bang? — senior signal.
- Do you distinguish "engine cutover" from "metadata migration"? — senior signal.
- Do you name Phase 5 as optional? — senior signal (running federated indefinitely is a valid end-state).
- Do you have a parity-verification story? — required answer (schema check, row-count check, sample-query check).
What senior interviewers actually probe.
- "Walk me through migrating from Glue to Polaris." — federation first, engines one at a time, optional metadata cut later.
- "Your CTO wants a 3-month migration; give me the phased plan." — Phase 1 (federation, 2 wk), Phase 2 (one engine, 2 wk), Phase 3 (all engines, 6 wk), Phase 4 (deprecate SDKs, ongoing), Phase 5 (optional).
- "How do you verify parity between legacy and Polaris?" — same DESCRIBE, same COUNT, same query on a canary table.
- "What's the rollback story?" — every engine keeps its legacy-catalog config in git; revert = one PR + restart per engine.
Worked example — federating Polaris from an existing AWS Glue catalog
Detailed explanation. The most common migration scenario. Company has 3000 Iceberg tables in Glue, uses EMR Spark to write and Athena to read. They want to add Trino for ad-hoc SQL and Snowflake for governed dashboards. Polaris is deployed pointing at Glue as the source of truth; Trino and Snowflake wire up to Polaris; EMR and Athena stay on Glue. Six months later, migrate EMR to Polaris. Twelve months later, decide whether to keep federating or migrate the metadata.
- Legacy. 3000 Iceberg tables in Glue, us-east-1, account 123456789012.
-
Federation. Polaris
EXTERNALcatalog withcatalog-impl=GlueCatalog. - First cutover. Trino ad-hoc cluster (new deployment, no legacy config to preserve).
Question. Configure Polaris to federate from Glue and cut over Trino.
Input.
| Component | Value |
|---|---|
| Legacy catalog | AWS Glue (us-east-1, 123456789012) |
| Table count | 3000 Iceberg tables |
| First cutover | Trino ad-hoc (greenfield) |
| Glue role for Polaris | arn:aws:iam::123456789012:role/polaris-glue-federation |
Code.
# 1. Create the federated catalog in Polaris
curl -X POST https://polaris.acme.internal/api/management/v1/catalogs \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "warehouse",
"type": "EXTERNAL",
"properties": {
"catalog-impl": "org.apache.iceberg.aws.glue.GlueCatalog",
"default-base-location": "s3://acme-lake/warehouse/",
"glue.region": "us-east-1",
"glue.catalog-id": "123456789012",
"glue.skip-name-validation": "true"
},
"storageConfigInfo": {
"storageType": "S3",
"roleArn": "arn:aws:iam::123456789012:role/polaris-glue-federation",
"allowedLocations": ["s3://acme-lake/warehouse/"]
}
}'
# 2. Trino cutover — same config as any greenfield Polaris deploy
# etc/catalog/warehouse.properties
connector.name=iceberg
iceberg.catalog.type=rest
iceberg.rest-catalog.uri=https://polaris.acme.internal
iceberg.rest-catalog.warehouse=warehouse
iceberg.rest-catalog.security=OAUTH2
iceberg.rest-catalog.oauth2.credential=trino:${env:POLARIS_TRINO_SECRET}
iceberg.rest-catalog.vended-credentials-enabled=true
-- 3. Parity check — same table in both catalogs must have same shape
-- (Run once against Glue direct, once against Polaris-fronted; diff)
-- Via Glue (Athena):
SELECT count(*), sum(total_cents)
FROM AwsDataCatalog.warehouse.sales.orders
WHERE created_at >= DATE '2026-07-01';
-- Via Polaris (Trino):
SELECT count(*), sum(total_cents)
FROM warehouse.sales.orders
WHERE created_at >= DATE '2026-07-01';
-- Both should return identical rows; any mismatch = parity failure = investigate before cutover
Step-by-step explanation.
- The
EXTERNALcatalog type in Polaris means "delegate to another catalog impl"; thecatalog-impl=GlueCatalogpicks Iceberg's Glue client.glue.region+glue.catalog-idscope Polaris to the correct Glue database; withoutcatalog-idPolaris talks to whichever Glue account its IAM role can access. - The
polaris-glue-federationIAM role must have (a)glue:GetDatabases/GetTables/UpdateTable/...for the target Glue catalog, and (b)sts:AssumeRoleon the storage role that Polaris will vend. Split these responsibilities across roles for auditability. - Trino's config is identical to a greenfield Polaris deploy — the fact that Polaris is federating vs storing is invisible to the client. This is the whole point of federation: engines see one Iceberg REST endpoint regardless of where the metadata physically lives.
- Parity checks are non-optional. Run the same query on the same table via both the legacy catalog and Polaris; compare row counts, aggregates, and sample records. Any mismatch is a signal to investigate before continuing the cutover — often it's schema-version drift or a Glue attribute Polaris hasn't translated.
- Rollback is trivial: revert Trino's
warehouse.propertiesto point at Glue directly; restart. The Polaris federation catalog can stay running indefinitely as an unused deployment, ready to re-enable.
Output.
| Step | Before (Glue direct) | After (Polaris federation) |
|---|---|---|
| Trino config | Glue catalog | Iceberg REST catalog |
| EMR / Athena config | Glue direct | Glue direct (unchanged) |
| Metadata source of truth | Glue | Glue (still — federation) |
| Storage IAM in Trino | needed | not needed (vended STS) |
| Rollback cost | reset config; restart Trino | (same) |
Rule of thumb. For any migration from Glue, start with an EXTERNAL federation catalog in Polaris. Cut over one non-critical engine first. Verify parity with row-count + sample-query checks. Never migrate table metadata out of Glue until you have run in federated mode for at least one full billing cycle without incident.
Worked example — the polaris-catalog-hms shim for Hive Metastore federation
Detailed explanation. A common legacy scenario. Company runs a 5-year-old data platform with 8000 tables in Hive Metastore (HMS 3.1) backed by MySQL, on-prem, behind a VPN. Most tables are Iceberg-format (migrated from Parquet over the last two years) but a few are still Hive-format. They want to add a cloud Trino cluster that talks Iceberg REST. Deploy Polaris in the cloud pointing at HMS via VPN; expose the Iceberg tables as REST. Walk through the setup.
- Legacy. HMS 3.1 + MySQL backend on-prem; 8000 tables (7500 Iceberg-format, 500 Hive-format).
- New engine. Trino cluster in the cloud.
-
Shim.
polaris-catalog-hmsor Iceberg-nativeHiveCatalogin anEXTERNALPolaris catalog. - Constraint. Hive-format tables cannot be exposed via Iceberg REST; only the 7500 Iceberg-format tables show up.
Question. Configure Polaris to federate from HMS and confirm which tables are exposed.
Input.
| Component | Value |
|---|---|
| HMS | Hive Metastore 3.1, MySQL backend, on-prem |
| VPN | site-to-site to cloud VPC |
| Tables | 7500 Iceberg + 500 Hive-format |
| Exposure target | Iceberg-format tables only |
Code.
# 1. Create the federated HMS catalog in Polaris
curl -X POST https://polaris.acme.internal/api/management/v1/catalogs \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "warehouse_hms",
"type": "EXTERNAL",
"properties": {
"catalog-impl": "org.apache.iceberg.hive.HiveCatalog",
"uri": "thrift://hms.on-prem.internal:9083",
"warehouse": "s3://acme-lake/warehouse/",
"hive.metastore.client.socket.timeout": "300",
"hive.metastore.client.pool.size": "16",
"engine.hive.enabled": "false"
},
"storageConfigInfo": {
"storageType": "S3",
"roleArn": "arn:aws:iam::123456789012:role/polaris-hms-federation",
"allowedLocations": ["s3://acme-lake/warehouse/"]
}
}'
# 2. List tables via Polaris — only Iceberg tables show up
import requests
def list_all_tables(rest_url: str, token: str, catalog: str) -> list[str]:
tables = []
namespaces = requests.get(f"{rest_url}/v1/{catalog}/namespaces",
headers={"Authorization": f"Bearer {token}"}).json()
for ns in namespaces["namespaces"]:
ns_path = "/".join(ns)
r = requests.get(f"{rest_url}/v1/{catalog}/namespaces/{ns_path}/tables",
headers={"Authorization": f"Bearer {token}"})
for t in r.json().get("identifiers", []):
tables.append(".".join(t["namespace"] + [t["name"]]))
return tables
polaris_tables = list_all_tables("https://polaris.acme.internal", TOKEN, "warehouse_hms")
print(f"Polaris exposes {len(polaris_tables)} Iceberg tables (of 8000 in HMS)")
# -> Polaris exposes 7500 Iceberg tables (of 8000 in HMS)
# 3. Trino connects to warehouse_hms exactly like any Polaris catalog
# etc/catalog/warehouse_hms.properties
connector.name=iceberg
iceberg.catalog.type=rest
iceberg.rest-catalog.uri=https://polaris.acme.internal
iceberg.rest-catalog.warehouse=warehouse_hms
iceberg.rest-catalog.security=OAUTH2
iceberg.rest-catalog.oauth2.credential=trino:${env:POLARIS_TRINO_SECRET}
iceberg.rest-catalog.vended-credentials-enabled=true
Step-by-step explanation.
-
HiveCataloginEXTERNALmode is the shim — Polaris opens Thrift connections to HMS on the back side, translates REST requests into HMS Thrift calls, and returns responses in Iceberg-REST shape. Theuri=thrift://...is standard HMS Thrift URI. - The
hive.metastore.client.pool.size=16tunes the Thrift connection pool; too small and REST requests queue behind HMS; too large and you exhaust HMS's connection limit. 16 is a reasonable starting point for a moderate-throughput Polaris. -
engine.hive.enabled=falsetells the IcebergHiveCatalogshim to skip Hive-format tables — only Iceberg-format tables are exposed via REST. The 500 legacy Hive tables remain accessible via direct HMS clients but do not appear in Polaris. - Listing tables from Polaris confirms the filter: 7500 tables show up, matching the Iceberg count in HMS. Any drift here (either direction) is a signal that some Iceberg tables have malformed metadata that the shim can't parse — usually a version mismatch or a manually-edited metadata file.
- Trino's config points at
warehouse=warehouse_hms— the specific Polaris catalog that federates from HMS. Trino sees nothing about HMS; it sees Iceberg REST. This is what makes engine cutover a one-line config change.
Output.
| Aspect | Direct HMS access | Polaris federation |
|---|---|---|
| Wire protocol | Thrift RPC | Iceberg REST HTTP |
| Table count | 8000 (Iceberg + Hive) | 7500 (Iceberg only) |
| Storage credentials | client-managed (long-lived) | Polaris-vended STS |
| Client SDK | HMS Thrift | Iceberg REST |
| Cutover cost per engine | (varies) | one config change |
Rule of thumb. For any HMS-to-Polaris migration, use the HiveCatalog shim in EXTERNAL mode with engine.hive.enabled=false. Confirm the exposed table count matches your Iceberg-format count in HMS. Non-Iceberg tables stay accessible via direct HMS; only new engines cut over to Polaris.
Worked example — cutting over from Unity Catalog to Polaris via federation
Detailed explanation. A Databricks-heavy shop has Unity Catalog as their primary catalog; a new Trino cluster and a Snowflake sink need read access to the same tables without introducing Databricks-specific SDKs on those engines. Databricks 2024+ ships Unity Catalog federation over an Iceberg-REST-compatible endpoint; combined with Polaris federation-to-UC, you can point Trino/Snowflake at Polaris and Polaris at UC. Databricks still owns writes; other engines get consistent reads.
- Primary. Databricks with Unity Catalog; 1200 tables (UniForm-enabled Iceberg).
- New engines. Trino cluster + Snowflake external volumes.
- Constraint. Databricks must remain the write path; other engines are read-only.
Question. Wire Polaris to federate from Unity Catalog and confirm read access from Trino.
Input.
| Component | Value |
|---|---|
| Primary catalog | Unity Catalog |
| Table count | 1200 UniForm-enabled Iceberg |
| Access mode | read-only (Trino + Snowflake) |
| Databricks host | https://acme.cloud.databricks.com |
Code.
# 1. Create the Polaris catalog that federates from Unity
curl -X POST https://polaris.acme.internal/api/management/v1/catalogs \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "warehouse_uc",
"type": "EXTERNAL",
"properties": {
"catalog-impl": "org.apache.iceberg.rest.RESTCatalog",
"uri": "https://acme.cloud.databricks.com/api/2.1/unity-catalog/iceberg",
"warehouse": "main",
"token": "${env:UC_ACCESS_TOKEN}"
},
"storageConfigInfo": {
"storageType": "S3",
"roleArn": "arn:aws:iam::123456789012:role/polaris-uc-federation",
"allowedLocations": ["s3://acme-lake/uc-managed/"]
}
}'
# 2. Trino config — talks to Polaris, which talks to UC
# etc/catalog/warehouse_uc.properties
connector.name=iceberg
iceberg.catalog.type=rest
iceberg.rest-catalog.uri=https://polaris.acme.internal
iceberg.rest-catalog.warehouse=warehouse_uc
iceberg.rest-catalog.security=OAUTH2
iceberg.rest-catalog.oauth2.credential=trino:${env:POLARIS_TRINO_SECRET}
iceberg.rest-catalog.vended-credentials-enabled=true
-- 3. Read a UC-managed table from Trino, via Polaris
SELECT customer_id, sum(total_cents) AS revenue
FROM warehouse_uc.main.sales_orders_uniform
WHERE event_date >= DATE '2026-07-01'
GROUP BY customer_id
ORDER BY revenue DESC
LIMIT 10;
-- Query returns; behind the scenes:
-- Trino -> Polaris REST -> UC federation REST -> UC metadata + S3 STS -> Trino reads S3
Step-by-step explanation.
- Databricks Unity Catalog exposes an Iceberg-REST-compatible endpoint at
/api/2.1/unity-catalog/iceberg; Polaris connects to this like it would to any other Iceberg REST catalog. The federation is REST-to-REST rather than REST-to-Thrift/SDK — different wire from Glue federation but the pattern is the same. - The
tokenproperty is a Databricks PAT (personal access token) or service-principal token used by Polaris to authenticate to UC. Rotate it via the standard Databricks token lifecycle; Polaris re-reads on config reload. - The UC federation IAM role (
polaris-uc-federation) needs read on the UC-managed S3 prefix. UC generates the STS credentials on its side; Polaris receives them and re-vends downstream. This is a two-layer vending — UC vends to Polaris, Polaris vends to the client engine. - Trino's config is again identical to any Polaris deploy — the whole federation chain is invisible.
SELECT * FROM warehouse_uc.main.sales_orders_uniformtriggers Polaris to talk to UC, which returns metadata + STS, which Polaris passes through to Trino. - Writes from Trino would fail here because Polaris is configured read-only against UC (UC's federation endpoint is read-only in 2024+). Databricks remains the write path; Trino and Snowflake are read-only. This is the deliberate architecture for a Databricks-primary shop.
Output.
| Layer | Role |
|---|---|
| Databricks | primary writer via Unity Catalog |
| Unity Catalog | source of truth for metadata + storage IAM |
| Polaris (warehouse_uc) | federation shim; read-through |
| Trino / Snowflake | Iceberg REST clients; read-only via Polaris |
| S3 | data plane; direct access via vended STS |
Rule of thumb. For UC-primary shops adding non-Databricks engines, Polaris federation over UC is the answer — Databricks keeps writes, other engines get consistent reads via Polaris. Do not attempt to make Trino write via UC federation; that path is intentionally read-only.
Senior interview question on migration
A senior interviewer might ask: "You inherit a data platform with 5000 Iceberg tables in AWS Glue, EMR Spark as the primary writer, Athena as the primary reader, and a proposal on the table to adopt Polaris. Leadership wants a 90-day migration to a state where Trino, Snowflake, and Databricks can all read and write these tables via Polaris. Design the phased plan, the parity-verification story, the rollback strategy, and the interview-worthy trade-offs you'd flag."
Solution Using EXTERNAL Glue federation, engine-by-engine cutover, and parity checks anchored in Polaris audit + Glue baseline
# Phase 1 (weeks 1-2) — deploy Polaris + federate from Glue
# (Config from the earlier worked example; deploy Polaris in EKS behind an ALB.)
# Phase 2 (weeks 3-4) — cut over one non-critical engine (new Trino)
# Provision Trino cluster; wire config; run parity checks.
# Phase 3 (weeks 5-10) — cut over Snowflake external volumes + Databricks read
# Both engines gain access via Polaris; both keep option to fall back to Glue.
# Phase 4 (weeks 11-12) — cut over EMR Spark writes
# Highest-risk step; parity checks per table before switching write path.
# Phase 5 (post-90-day) — decide keep-federating vs migrate metadata to Polaris
# Optional; running federated indefinitely is a valid end-state.
# Parity harness — run per table before a write-path cutover
import boto3
import requests
def parity_check(table_id: str, glue_client, polaris_url, polaris_token):
"""Compare Glue-fronted and Polaris-fronted views of the same table."""
# 1. Glue view
g_meta = glue_client.get_table(DatabaseName=table_id.split(".")[0],
Name=table_id.split(".")[1])["Table"]
# 2. Polaris view
p_meta = requests.get(
f"{polaris_url}/v1/warehouse/namespaces/{table_id.split('.')[0]}/tables/{table_id.split('.')[1]}",
headers={"Authorization": f"Bearer {polaris_token}"},
).json()["metadata"]
diffs = []
if g_meta["StorageDescriptor"]["Location"] != p_meta["location"]:
diffs.append(("location", g_meta["StorageDescriptor"]["Location"], p_meta["location"]))
if len(g_meta.get("PartitionKeys", [])) != len(p_meta.get("partition-spec", {}).get("fields", [])):
diffs.append(("partition-count",
len(g_meta.get("PartitionKeys", [])),
len(p_meta.get("partition-spec", {}).get("fields", []))))
return diffs
def parity_data_check(table_id: str, athena_client, trino_client):
"""Run count(*) + sum(sample column) via both engines; compare."""
q = f"SELECT count(*) AS n, sum(total_cents) AS s FROM warehouse.{table_id} WHERE created_at >= DATE '2026-07-01'"
a = athena_client.execute(q) # Glue-fronted
t = trino_client.execute(q.replace("warehouse.", "warehouse_polaris.")) # Polaris-fronted
return a == t
# Rollback contract — every engine keeps two config files in git
# engine/warehouse.polaris.properties
# engine/warehouse.glue.properties
# `warehouse.properties` is a symlink; flip the symlink + restart to rollback.
Step-by-step trace.
| Phase | Weeks | Action | Rollback |
|---|---|---|---|
| 1 | 1-2 | Deploy Polaris; federate Glue | uninstall Polaris; no engine change |
| 2 | 3-4 | Cut over new Trino | revert Trino config to Glue |
| 3 | 5-10 | Cut over Snowflake + Databricks (reads) | revert each engine's config |
| 4 | 11-12 | Cut over EMR Spark writes | revert EMR; run reconcile job |
| 5 | 90+ | Optional metadata migration | (do not attempt until Phase 4 stable for 30 days) |
After the plan, the cluster is running Polaris-fronted for all four engines at day 84; the 6-day buffer is for parity investigation of edge-case tables. Rollback per phase is one config flip per engine — every engine holds both Polaris and Glue configs in git, and the active config is a symlink. The metadata cutover (Phase 5) is deferred indefinitely unless a specific business case (cost, SLA, feature) forces it.
Output:
| Metric | Value |
|---|---|
| Total migration duration | 90 days (12 weeks + buffer) |
| Engine cutover cost | 1 config change + 1 restart per engine |
| Parity check coverage | 100% of production tables |
| Rollback SLA | < 15 min per engine (config flip + restart) |
| Post-migration source of truth | Glue (federated), unless Phase 5 triggered |
| Interview-worthy trade-off | keep-federating vs full-migration (bounded ops complexity vs single-catalog simplicity) |
Why this works — concept by concept:
- EXTERNAL federation — Polaris talks Iceberg REST on the front, delegates to Glue on the back. Migration becomes a per-engine cutover rather than a per-table migration. The blast radius shrinks by orders of magnitude.
- Engine-by-engine cutover order — start with the non-critical, low-throughput, greenfield engine (new Trino). Save the highest-risk step (EMR writes) for last, after every earlier cutover has bedded in. This is the standard risk-ordered migration playbook.
- Parity harness — metadata parity (Glue vs Polaris view of the same table) plus data parity (identical queries via Athena and Trino) catches drift before a cutover. Do not rely on "it looked fine yesterday."
-
Two-config git contract — every engine holds both
warehouse.polaris.propertiesandwarehouse.glue.properties; the active config is a symlink; rollback isln -sf+ restart. This buys 15-minute rollback SLA at the cost of one extra file per engine. - Cost — one Polaris deployment (~2 vCPU + 4 GB + Postgres), ~2 engineer-weeks per phase, one parity harness (reusable), one git repo of engine configs. The eliminated cost is the "big-bang migration" outage risk and the "we can't try Polaris without committing to it" adoption barrier. Net O(engines) per migration rather than O(tables) — for 5000 tables and 4 engines, that's 4 units of work rather than 5000. Post-migration, running in federated mode indefinitely is a valid end-state and often the correct one.
Design
Topic — design
Design problems on lakehouse migration
SQL
Topic — sql
SQL practice on federation and parity checks
Cheat sheet — Polaris recipes
- Which catalog when. Apache Polaris is the 2026 default for any multi-engine lakehouse where you cannot commit to a single vendor's compute — the Iceberg REST Catalog Spec is the shared protocol every Iceberg-native engine already speaks, and Polaris is the Apache-licensed reference implementation. Unity Catalog is the answer when Databricks is your primary compute and UniForm-Iceberg reads are enough elsewhere. AWS Glue is the answer when you are AWS-only with heavy Athena/Redshift usage and can accept IAM-passthrough credentials rather than vending. Nessie is the answer when git-like branch/merge semantics on tables is a first-class requirement. Never pick a catalog because a vendor's rep suggested it; walk the four-axis matrix (protocol, vending, federation, governance) with the actual engines and constraints on the table.
-
REST endpoint + OAuth2 config template. Every Iceberg-native engine takes the same five settings:
iceberg.catalog.type=rest,iceberg.rest-catalog.uri=https://polaris.example.com,iceberg.rest-catalog.warehouse=<catalog-name>,iceberg.rest-catalog.oauth2.credential=<client_id>:<client_secret>, andiceberg.rest-catalog.vended-credentials-enabled=true(or equivalentlyheader.X-Iceberg-Access-Delegation=vended-credentials). Store the client_secret in an environment variable or secrets manager, never in the config file. Scope the OAuth2 token viaoauth2.scope=PRINCIPAL_ROLE:<role>so the engine only gets the permissions its intended team needs. Enable token refresh (token-refresh-enabled=true) for long-running Spark/Flink jobs. -
IAM boundary for storage credential vending. Polaris holds one catalog-owner IAM role per storage location (e.g.
polaris-warehouse-role) that has broad access to the warehouse bucket. On every table load, PolarisAssumeRolewith a session policy that whittles permissions down to the exact prefix and privilege of the requested table —s3:GetObjectons3://acme-lake/warehouse/sales/orders/*for a read, pluss3:PutObjectands3:DeleteObjectfor a write. Duration is 3600 seconds by default (1 hour). Never let engines hold long-lived IAM identities to a lakehouse bucket; always vend short-lived STS sessions per table load. Every credential leak is then scoped to one table for one hour. -
Trino / Spark connection strings for Polaris. Trino uses
etc/catalog/warehouse.propertieswithconnector.name=iceberg,iceberg.catalog.type=rest, and the five standard settings. Spark uses SparkSession config withspark.sql.catalog.warehouse=org.apache.iceberg.spark.SparkCatalogandspark.sql.catalog.warehouse.catalog-impl=org.apache.iceberg.rest.RESTCatalog. Flink usesCREATE CATALOGDDL with'type'='iceberg'and'catalog-impl'='org.apache.iceberg.rest.RESTCatalog'. Snowflake uses external volumes wired to the same REST endpoint. In all four cases the only engine-specific piece is the wrapper syntax; the actual settings are identical. -
Namespace-level RBAC grant recipe. Create a principal role per team (
sales_analyst,sales_etl,sales_bi); create a catalog role per (team, capability) pair on each catalog (sales_reader,sales_writer,sales_bi_reader); attach grants to catalog roles at the namespace level, not the table level, so the RBAC surface stays bounded. Wire principal roles to catalog roles with the management API. Use tier metadata on namespaces (tier=public|internal|confidential|restricted) to drive automated grant policies — a basetier_internal_readerrole that reads all namespaces tagged internal or below. -
Federation config for Glue / HMS / Unity. Glue:
catalog-impl=org.apache.iceberg.aws.glue.GlueCatalog, setglue.regionandglue.catalog-id; the Polaris IAM role needsglue:Get*plus storageAssumeRole. HMS:catalog-impl=org.apache.iceberg.hive.HiveCatalog, seturi=thrift://hms:9083andengine.hive.enabled=falseto expose only Iceberg-format tables. Unity Catalog:catalog-impl=org.apache.iceberg.rest.RESTCatalogpointing at the UC federation endpoint/api/2.1/unity-catalog/icebergwith a Databricks token; federation is read-only (writes stay in UC/Databricks). -
Migration checklist per source catalog. (a) Deploy Polaris with
EXTERNALfederation to the source catalog. (b) Run parity harness (metadata parity + data parity via count/sum comparisons) on every production table. (c) Cut over one non-critical engine first (usually a new Trino cluster). (d) Cut over other reader engines (Snowflake, Databricks readers) one at a time. (e) Cut over writer engines (EMR, Spark ETL) last. (f) Keep two config files per engine in git (warehouse.polaris.propertiesandwarehouse.<legacy>.properties) with the active one as a symlink; rollback is aln -sf+ restart. (g) Decide separately, later, whether to migrate metadata out of the legacy catalog. -
Slot / commit-conflict monitoring queries. Emit Prometheus metrics per commit:
iceberg_commit_attempts_total,iceberg_commit_conflicts_total,iceberg_commit_success_total,iceberg_commit_latency_seconds. Alert on conflict rate > 5% sustained for 10 minutes — that's the signal that two writers are contending on the same partition/table hot path. Long-term fixes: (a) partition writes so each writer targets disjoint files, (b) reduce the commit frequency of the noisiest writer, (c) serialise writers via an external distributed lock if truly no other option. On the Polaris side, monitorpolaris_rest_request_secondsandpolaris_credential_vend_secondshistograms to catch STS throttling or Postgres slowness. - Decision matrix: Polaris vs Unity vs Glue vs Nessie. Protocol: Polaris = Iceberg REST (open), Unity = Unity REST + UniForm (open source but Databricks-governed), Glue = Glue SDK (AWS-specific), Nessie = Nessie REST (open, smaller ecosystem). Credential vending: Polaris = S3 STS / Azure SAS / GCS signed URLs (native), Unity = UC-managed IAM, Glue = IAM passthrough (no vending), Nessie = varies by deployment. Federation: Polaris = from Glue/HMS/UC (native), Unity = from HMS (limited), Glue = source of truth (none), Nessie = git-native (no federation). Governance: Polaris = 4-level RBAC, Unity = 3-level RBAC, Glue = IAM + Lake Formation, Nessie = git-permissions.
-
The optimistic-lock commit contract. Iceberg commits are compare-and-swap on
snapshot_id— the writer POSTsexpected-current-snapshot-id=<what it read>with the new snapshot proposal; Polaris advances the pointer on match, returns 409 Conflict on mismatch. The engine's Iceberg client catches the 409, re-reads the current snapshot, and retries. Appends are always commutative (retry safely); overwrites may need re-computation. Set retry to 8 attempts with exponential backoff + jitter; cap the max delay at ~12 seconds. Conflict rates under 1% in steady state are expected on multi-writer tables; sustained rates above 5% signal a contention hotspot that needs a design change. -
The four RBAC levels — memorise them. Principal (authenticated identity — user or service account with
client_id), principal_role (organisational grouping likeanalystoretl_writer), catalog_role (per-catalog permission bundle likewarehouse_reader), grant (atomic privilege likeTABLE_READ_DATAon a namespace or table). Grants live on catalog roles, never on principal roles. Principals get one or more principal_roles; principal_roles get one or more catalog_roles per catalog; catalog_roles hold the grants. Every "why can Alice read this" investigation walks this chain in reverse from the principal. -
Audit stitching contract. Turn on Polaris audit logging (
polaris.audit.enabled=true, JSON format). Every credential vend emits an event with the session name (polaris-<principal>-<uuid>). CloudTrail records the AWS-sideAssumeRolewith the same session name plus every subsequent S3 API call. Stitch the two streams bysession_nameto produce a single-row-per-access report:(principal, catalog, namespace, table, vended_at, s3_get_count, s3_put_count). Load into a warehouse table; run BI dashboards for compliance requests. Every "who touched this table last week" question becomes a 5-second SQL query rather than a 5-day investigation. - What senior interviewers score highest. Naming the Iceberg REST Catalog Spec (not just "an API") in sentence one; distinguishing "open protocol" from "open source" when comparing Polaris and Unity; naming credential vending as the security primitive (not "IAM"); naming federation as the migration bridge (not big-bang); naming the optimistic-lock CAS as the multi-writer safety mechanism (not "locks"); naming the four RBAC levels in order; naming Snowflake Open Catalog as the managed variant of Apache Polaris. These are the senior signals that separate architects who have designed a Polaris deployment from candidates who have only read the docs.
Frequently asked questions
What is Apache Polaris in one sentence?
Apache Polaris is the Apache Software Foundation-hosted reference implementation of the Iceberg REST Catalog Spec — an HTTP-based, vendor-neutral catalog server for Apache Iceberg tables that speaks a single protocol every Iceberg-native engine (Trino, Spark, Flink, Snowflake, Dremio, Athena) already knows, holds table metadata in a pluggable persistence layer (Postgres in the reference deployment), and vends short-lived, scoped storage credentials (S3 STS, Azure SAS, GCS signed URLs) so engines never receive the long-lived IAM identity that owns the underlying bucket. Snowflake open-sourced it in 2024 as "Polaris Catalog", donated the code to the ASF, and also ships a managed variant called "Snowflake Open Catalog"; the ASF project is the community-governed source of truth while the managed variant is the same code with Snowflake operating it. Every senior data-engineering interview in 2026 probes Polaris because the choice of catalog is the single biggest lock-in decision in a modern lakehouse.
Polaris vs Unity Catalog — when do I pick each?
Default to Apache Polaris when your lakehouse is multi-engine (Trino + Spark + Snowflake + …) and you want a vendor-neutral protocol every engine speaks natively; Polaris implements the open Iceberg REST Catalog Spec, so no engine needs a vendor-specific SDK to participate. Default to Unity Catalog when Databricks is your primary compute; UC is the natural fit for Delta Lake plus UniForm-translated Iceberg reads from other engines. The key architectural distinction is open protocol versus open source: Polaris is both (the REST spec is a public protocol and the code is Apache-licensed and ASF-hosted); Unity Catalog is only the latter (the code was open-sourced in 2024 but governance stays with Databricks, and Iceberg support goes through UniForm rather than being first-class). Feature-wise both offer role-based ACLs, credential management, and lineage; the deciding question is almost always "what is my primary compute?" — Databricks-primary means UC, everything-else means Polaris. Federation lets you run both: Polaris federated from UC keeps Databricks writes while opening reads to Trino/Snowflake.
What is the Iceberg REST Catalog Spec and why does it matter?
The Iceberg REST Catalog Spec is a public, versioned HTTP protocol that defines exactly how an Iceberg-native engine talks to a catalog: OAuth2 token exchange at /v1/oauth/tokens, namespace listing at /v1/{prefix}/namespaces, table load at /v1/{prefix}/namespaces/{ns}/tables/{table} (which returns both metadata and vended storage credentials), commit at the same URL via POST with expected-current-snapshot-id for the optimistic-lock CAS. Before this spec existed, every catalog had its own SDK — engines linked against Glue-SDK, HMS Thrift, Nessie's Java client, and so on — and swapping catalogs meant recompiling engines. With the spec, engines implement one client and can point at any REST-compliant catalog: Polaris, Snowflake Open Catalog, Nessie's REST endpoint, Databricks Unity Catalog's REST endpoint, Tabular's endpoint (until Databricks acquired them), and so on. It is the "USB-C of Iceberg catalogs" — one connector, many implementations. Polaris is the reference implementation; other implementations compete on features, ops, and pricing while keeping the wire protocol identical.
How does storage credential vending work?
Polaris holds one long-lived catalog-owner IAM role per storage location — for example, polaris-warehouse-role with s3:* on s3://acme-lake/warehouse/*. When an engine loads a table via GET /v1/{prefix}/namespaces/{ns}/tables/{table}, Polaris checks RBAC to confirm the caller has the required privilege (say, TABLE_READ_DATA on warehouse.sales.orders), then calls AWS STS AssumeRole on the catalog-owner role with a session policy that whittles the permissions down to the specific table prefix and privilege — s3:GetObject on s3://acme-lake/warehouse/sales/orders/* for a read, plus s3:PutObject and s3:DeleteObject for a write. STS intersects the identity policy of the role with the session policy and returns a session credential (access key, secret, session token) with the narrower set of permissions and a bounded TTL (typically 1 hour). Polaris returns these credentials in the config block of the table-load response; the engine builds an S3 client from them and reads Parquet directly. Polaris is completely out of the data path — only in the metadata path. Every credential leak is scoped to one table for one hour rather than to your entire lake, and the bucket owner's IAM identity never leaves Polaris.
Can Polaris federate from Glue or HMS?
Yes — federation is one of Polaris's headline features and the reason it is realistic for existing shops rather than just greenfield deployments. Polaris supports two federation modes: EXTERNAL catalogs where the metadata lives elsewhere and Polaris proxies REST calls to the source, and INTERNAL catalogs where Polaris owns the metadata directly in its own persistence store. For AWS Glue federation, configure an EXTERNAL catalog with catalog-impl=org.apache.iceberg.aws.glue.GlueCatalog, glue.region, and glue.catalog-id; Polaris translates Iceberg REST requests into Glue SDK calls. For Hive Metastore federation, use catalog-impl=org.apache.iceberg.hive.HiveCatalog with uri=thrift://hms:9083 and engine.hive.enabled=false to expose only Iceberg-format tables; the shim maps REST calls to HMS Thrift RPCs. For Unity Catalog federation, use catalog-impl=org.apache.iceberg.rest.RESTCatalog pointing at UC's federation endpoint /api/2.1/unity-catalog/iceberg with a Databricks token; UC federation is read-only (writes stay in UC/Databricks). In every case the client-facing REST endpoint is identical — engines see one Polaris catalog and cannot tell where the metadata actually lives.
Is Polaris production-ready in 2026?
Yes for the managed variant and yes for the ASF release. Snowflake Open Catalog (Snowflake's managed Polaris) has been generally available since 2024 and is used by Snowflake's enterprise customers for their open-table workloads; SLAs, support, and operational maturity match what you'd expect from a Snowflake product. Apache Polaris (the ASF community release) reached incubator maturity in 2024 and the graduating releases in 2025-2026 are running in production at Netflix, Salesforce, and other large deployments. The remaining rough edges in 2026 are largely around operations: the self-hosted Postgres persistence layer needs the same care as any other stateful service (backups, replicas, tuning), the credential-vending IAM setup requires careful role-splitting for high-throughput deployments (per-team AssumeRole roles to avoid AWS STS throttling limits), and the observability story around Iceberg commit conflicts benefits from investment in engine-side metrics. None of these are blockers; they are the standard "run a stateful service in production" concerns. The interviewer's question here is almost always "is it mature enough to bet on" — the answer in 2026 is unambiguously yes, with the caveat that you must run it operationally the way you would any other tier-1 metadata store.
Practice on PipeCode
- Drill the SQL practice library → for the aggregation, join, and window-function problems senior interviewers use to test Iceberg-catalog-driven query patterns.
- Rehearse system design against the design practice library → for open-catalog architecture, RBAC-model, and migration-planning scenarios that mirror the Polaris design interview.
- Sharpen the streaming axis with the streaming practice library → for Debezium + Iceberg-sink + Polaris-commit-conflict problems and the multi-engine coordination patterns that show up in senior lakehouse interviews.
- Warm up on the aggregation practice library → for the tumble/session/rolling-window shapes that dominate Iceberg dashboarding workloads.
- Practise the joins practice library → for the fact-dimension and slowly-changing-dimension patterns typical of catalog-driven warehouse designs.
- Stack the prerequisites against PipeCode's broader 450+ data-engineering catalogue to anchor the Polaris design matrix, the four RBAC levels, and the three federation paths against real graded inputs.
Lock in apache polaris muscle memory
Docs explain what Polaris is. PipeCode drills explain when it wins — when the Iceberg REST spec matters, when credential vending saves your incident review, when federation is the only path off Glue, when the optimistic-lock CAS is the concurrency contract that keeps four engines honest. Pipecode.ai is Leetcode for Data Engineering — pattern-first practice tuned for the production trade-offs senior data engineers actually face when they choose an open catalog.





Top comments (0)