The decoupling of compute from storage has positioned Apache Iceberg as the de facto open table format across enterprise cloud data architectures. By providing ACID transactions, schema evolution without rewriting data files, hidden partitioning, and time-travel snapshots, Iceberg enables organizations to store petabytes of analytical data in Google Cloud Storage (GCS) while querying it via heterogeneous engines.
However, bridging autonomous Large Language Model (LLM) agents with production Iceberg lakehouses creates severe architectural challenges:
-
Financial Denial-of-Service (FDOS) & Runaway Queries: In a lakehouse with 50+ TB tables, an unconstrained LLM generating an unpartitioned
SELECT *or multi-way cross-join will trigger multi-hundred-dollar scanning fees in seconds. -
Dynamic Schema Drift & Partition Transform Hallucinations: Apache Iceberg separates logical table schemas from physical partition specs (
identity,year,month,bucket[N],truncate[W]). Traditional static prompt engineering inevitably causes models to hallucinate non-existent partition columns or miss partition pruning entirely. -
Destructive Mutation Risks: Semantic misinterpretations can cause models to emit non-idempotent or destructive DDL/DML statements (
DROP,DELETE,TRUNCATE,ALTER).
To solve these problems, I architected and built an end-to-end, production-grade Autonomous Data Agent using Googleβs Agent Development Kit (ADK) design patterns, Gemini 2.5 Flash, BigLake Iceberg REST Catalogs, and Abstract Syntax Tree (AST) guardrails. In this article, I will walk you through how I designed and implemented this system.
ποΈ System Architecture Topology
In my architecture, the agent never directly touches physical data files or executes raw, unvetted SQL against storage engines. Instead, I designed it to operate inside a closed-loop, deterministic execution framework structured across three distinct tiers:
User Natural Language Query
β
βΌ
βββββββββββββββββββββββββββββββββββββ
β Gemini 2.5 Flash Orchestrator β
β (Temperature: 0.0, Seed-Locked) β
βββββββββββββββββββ¬ββββββββββββββββββ
β
ββββββββββββββββββββββββ΄βββββββββββββββββββββββ
β β
βΌ βΌ
ββββββββββββββββββββββββ βββββββββββββββββββββββββββ
β Tier 1: Discovery β β Tier 2: Validation β
β Iceberg REST Catalog β β AST Security Inspection β
β Partition Layouts β β BigQuery Zero-Cost Dry β
β Active Snapshots β β Scan Budget Gate (<500M)β
ββββββββββββ¬ββββββββββββ ββββββββββββββ¬βββββββββββββ
β β
β [Validated AST + Estimated Cost Passed] β
ββββββββββββββββββββββββ¬ββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββ
β Tier 3: Vectorized Engine β
β BigQuery Native Vectorizedβ
β Parquet Pushdown via GCS β
βββββββββββββββ¬ββββββββββββββ
β
βΌ
Bounded Result Dataframe
β
βΌ
Synthesized Analytical Response
The Three Operational Tiers:
- Tier 1: Deep Lakehouse Metadata & Partition Probing: The agent inspects the BigLake Iceberg REST Catalog at runtime to dynamically inspect the active schema, column types, clustering keys, and physical partition transforms.
- Tier 2: Static AST Analysis & Zero-Cost Dry-Run Guardrails: The generated SQL undergoes Abstract Syntax Tree (AST) token parsing via SQLGlot to ensure read-only immutability. It is then compiled through BigQueryβs zero-cost dry-run API to enforce an absolute byte scanning budget (<500 MB).
- Tier 3: Governed Vectorized Read Execution: The approved query is executed using BigLake's vectorized Parquet reader directly over Cloud Storage, returning a strictly bounded result payload.
π¦ Environment Setup & Dependencies
Ensure your execution environment has the required Google Cloud and parsing dependencies installed:
pip install google-genai google-cloud-bigquery sqlglot pydantic
import os
import json
from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field
import sqlglot
from sqlglot import exp
from google import genai
from google.genai import types
from google.cloud import bigquery
from google.cloud.exceptions import GoogleCloudError
# Configuration & Runtime Limits
PROJECT_ID = os.environ.get("GCP_PROJECT_ID", "enterprise-lakehouse-prod")
LOCATION = os.environ.get("GCP_REGION", "us-central1")
MAX_SCAN_LIMIT_BYTES = 500 * 1024 * 1024 # Strict 500 MB Safety Ceiling
# Initialize Clients
bq_client = bigquery.Client(project=PROJECT_ID, location=LOCATION)
ai_client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
π§ Tier 1: Iceberg REST Catalog & Metadata Discovery Tool
Passing static schemas inside system prompts fails because modern Iceberg tables continuously evolve. To solve this, I built a metadata discovery tool that allows my agent to inspect table schemas, field constraints, partitioning strategies, and clustering keys dynamically at runtime:
class ColumnDefinition(BaseModel):
name: str
data_type: str
mode: str
description: Optional[str] = None
class TableMetadataResponse(BaseModel):
table_id: str
table_type: str
columns: List[ColumnDefinition]
partition_specs: List[str]
clustering_keys: List[str]
def get_lakehouse_table_schema(dataset_id: str, table_id: str) -> str:
"""
Queries the BigLake Iceberg REST Catalog to extract dynamic table schema,
column types, partition transformations, and clustering configurations.
"""
full_table_path = f"{PROJECT_ID}.{dataset_id}.{table_id}"
try:
table = bq_client.get_table(full_table_path)
except GoogleCloudError as e:
return f"ERROR: Failed to resolve table '{full_table_path}': {str(e)}"
columns = []
for field in table.schema:
columns.append(
ColumnDefinition(
name=field.name,
data_type=field.field_type,
mode=field.mode,
description=field.description or "No description provided"
)
)
partition_specs = []
if table.time_partitioning:
partition_specs.append(
f"Time Partition Field: '{table.time_partitioning.field}' "
f"(Type: {table.time_partitioning.type_})"
)
if table.range_partitioning:
partition_specs.append(
f"Range Partition Field: '{table.range_partitioning.field}'"
)
clustering_keys = table.clustering_fields if table.clustering_fields else []
metadata = TableMetadataResponse(
table_id=full_table_path,
table_type=table.table_type,
columns=columns,
partition_specs=partition_specs,
clustering_keys=clustering_keys
)
return metadata.model_dump_json(indent=2)
π‘οΈ Tier 2: AST Security Analysis & BigQuery Dry-Run Guardrails
To prevent SQL injection, data exfiltration, or accidental multi-terabyte full-table scans, I implemented a validation gate that runs two sequential security and cost layers before any query can touch the lakehouse:
-
AST Token Traversal: Disallows all non-SELECT queries, subquery mutations, and administrative commands using
sqlglot. -
Zero-Cost Dry Run: Pre-compiles the query on Google Cloud to calculate the exact
total_bytes_processedbefore billing occurs.
def audit_and_estimate_sql_cost(sql_statement: str) -> str:
"""
Performs Abstract Syntax Tree (AST) validation and a zero-cost BigQuery dry-run.
Enforces a strict 500MB scan ceiling and blocks all non-SELECT DDL/DML statements.
"""
# -------------------------------------------------------------
# Layer 1: Abstract Syntax Tree (AST) Immutability Check
# -------------------------------------------------------------
try:
parsed_tree = sqlglot.parse_one(sql_statement, read="bigquery")
except Exception as parse_err:
return json.dumps({
"status": "REJECTED",
"reason": f"SQL Syntax/Parsing Error: {str(parse_err)}"
})
# Ensure the root expression is strictly a SELECT or UNION
if not isinstance(parsed_tree, (exp.Select, exp.Union)):
return json.dumps({
"status": "REJECTED",
"reason": "Root statement must be a read-only SELECT or UNION query."
})
# Disallow destructive or mutating expressions anywhere in the AST
forbidden_ast_nodes = (
exp.Drop, exp.Delete, exp.TruncateTable, exp.Insert,
exp.Update, exp.AlterTable, exp.Command, exp.Merge
)
for node in parsed_tree.walk():
if isinstance(node, forbidden_ast_nodes):
return json.dumps({
"status": "REJECTED",
"reason": f"Forbidden mutation expression detected: {node.key.upper()}"
})
# -------------------------------------------------------------
# Layer 2: BigQuery Dry-Run Scan Evaluation (Zero-Cost Metric)
# -------------------------------------------------------------
job_config = bigquery.QueryJobConfig(
dry_run=True,
use_query_cache=False
)
try:
dry_run_job = bq_client.query(sql_statement, job_config=job_config)
scanned_bytes = dry_run_job.total_bytes_processed
scanned_mb = round(scanned_bytes / (1024 * 1024), 2)
if scanned_bytes > MAX_SCAN_LIMIT_BYTES:
limit_mb = round(MAX_SCAN_LIMIT_BYTES / (1024 * 1024), 2)
return json.dumps({
"status": "REJECTED",
"scanned_mb": scanned_mb,
"limit_mb": limit_mb,
"reason": (
f"Query scan budget exceeded: Proposed query scans {scanned_mb} MB, "
f"which violates the {limit_mb} MB safety limit. "
"Action Required: Prune partitions by narrowing date/timestamp or cluster filters."
)
})
return json.dumps({
"status": "APPROVED",
"scanned_bytes": scanned_bytes,
"scanned_mb": scanned_mb,
"message": "Cost budget check passed. Query is safe to execute."
})
except GoogleCloudError as gcp_err:
return json.dumps({
"status": "REJECTED",
"reason": f"BigQuery Compilation Failure: {str(gcp_err)}"
})
β‘ Tier 3: Governed Vectorized Read Execution
Once Tier 2 returns APPROVED, I dispatch the query through BigLake's vectorized query engine over Apache Iceberg Parquet files, enforcing a job-level billing cap as defense-in-depth:
def execute_lakehouse_query(sql_statement: str) -> str:
"""
Executes a vetted, read-only analytical query against Apache Iceberg via BigLake.
Returns the tabular result set capped to 50 rows to prevent memory saturation.
"""
# Enforce maximum billing ceiling at the job level as defense-in-depth
job_config = bigquery.QueryJobConfig(
maximum_bytes_billed=MAX_SCAN_LIMIT_BYTES
)
try:
query_job = bq_client.query(sql_statement, job_config=job_config)
results = query_job.result(max_results=50)
rows = [dict(row) for row in results]
if not rows:
return json.dumps({"status": "SUCCESS", "message": "Query executed successfully. Result set is empty.", "data": []})
return json.dumps({"status": "SUCCESS", "row_count": len(rows), "data": rows}, default=str)
except GoogleCloudError as err:
return json.dumps({"status": "EXECUTION_ERROR", "error": str(err)})
π€ Orchestration Loop with Gemini 2.5 Flash
Next, I instantiate Gemini 2.5 Flash using the official google-genai SDK, binding my three deterministic tools and establishing an immutable verification protocol:
SYSTEM_INSTRUCTION = """
You are an Autonomous Data Lakehouse Agent governing Apache Iceberg tables on Google Cloud.
You interact with production analytics systems through a strict three-tier verification lifecycle.
MANDATORY PROTOCOL:
1. DISCOVERY: ALWAYS call `get_lakehouse_table_schema` before authoring any SQL. Never guess column names or partition schemes.
2. SYNTHESIS: Write standard GoogleSQL optimized for Iceberg partition pruning.
3. VALIDATION: Pass your candidate SQL to `audit_and_estimate_sql_cost`.
4. SELF-CORRECTION: If status is 'REJECTED':
- Analyze the reason carefully.
- If rejected due to scan limits, add tighter partition bounds (e.g. narrow timestamp filters).
- Re-submit to `audit_and_estimate_sql_cost`.
5. EXECUTION: ONLY call `execute_lakehouse_query` after receiving an explicit 'APPROVED' status.
6. INSIGHT: Provide structured, executive-level answers with key metrics highlighted.
"""
def create_autonomous_lakehouse_agent():
return ai_client.chats.create(
model="gemini-2.5-flash",
config=types.GenerateContentConfig(
system_instruction=SYSTEM_INSTRUCTION,
tools=[
get_lakehouse_table_schema,
audit_and_estimate_sql_cost,
execute_lakehouse_query
],
temperature=0.0 # Zero temperature for deterministic architectural accuracy
)
)
π§ͺ Trace Walkthrough: Autonomous Recovery from Budget Violations
Here is a real execution trace from my test environment showing how the agent autonomously diagnoses and recovers from an excessive partition scan:
if __name__ == "__main__":
agent = create_autonomous_lakehouse_agent()
prompt = (
"In the dataset 'iot_telemetry', inspect table 'fleet_sensors'. "
"Identify the top 5 vehicles reporting anomalous thermal warnings."
)
response = agent.send_message(prompt)
print("\n=== FINAL AGENT SYNTHESIS ===")
print(response.text)
Complete Execution Trace:
[STEP 1: METADATA PROBING]
--> Call: get_lakehouse_table_schema(dataset_id="iot_telemetry", table_id="fleet_sensors")
<-- Response: {
"table_id": "enterprise-lakehouse-prod.iot_telemetry.fleet_sensors",
"table_type": "EXTERNAL",
"columns": [
{"name": "vehicle_id", "data_type": "STRING", "mode": "REQUIRED"},
{"name": "recorded_at", "data_type": "TIMESTAMP", "mode": "REQUIRED"},
{"name": "engine_temp_c", "data_type": "FLOAT", "mode": "NULLABLE"},
{"name": "anomaly_flag", "data_type": "BOOLEAN", "mode": "NULLABLE"}
],
"partition_specs": ["Time Partition Field: 'recorded_at' (Type: DAY)"],
"clustering_keys": ["vehicle_id"]
}
[STEP 2: NAIVE QUERY FORMULATION & AUDIT]
--> Call: audit_and_estimate_sql_cost(
sql_statement="SELECT vehicle_id, COUNT(*) as anomalies FROM `enterprise-lakehouse-prod.iot_telemetry.fleet_sensors` WHERE anomaly_flag = TRUE GROUP BY 1 ORDER BY anomalies DESC LIMIT 5"
)
<-- Response: {
"status": "REJECTED",
"scanned_mb": 1845.20,
"limit_mb": 500.0,
"reason": "Query scan budget exceeded: Proposed query scans 1845.2 MB, which violates the 500.0 MB safety limit. Action Required: Prune partitions by narrowing date/timestamp or cluster filters."
}
[STEP 3: AUTONOMOUS PARTITION PRUNING & RE-AUDIT]
--> Internal Agent Reasoning: The query scanned the entire historical Iceberg dataset (1.84 GB). I must restrict the `recorded_at` partition column to a recent time boundary.
--> Call: audit_and_estimate_sql_cost(
sql_statement="SELECT vehicle_id, COUNT(*) as anomalies FROM `enterprise-lakehouse-prod.iot_telemetry.fleet_sensors` WHERE anomaly_flag = TRUE AND recorded_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 14 DAY) GROUP BY 1 ORDER BY anomalies DESC LIMIT 5"
)
<-- Response: {
"status": "APPROVED",
"scanned_bytes": 68157440,
"scanned_mb": 65.0,
"message": "Cost budget check passed. Query is safe to execute."
}
[STEP 4: EXECUTION]
--> Call: execute_lakehouse_query(...)
<-- Response: {
"status": "SUCCESS",
"row_count": 5,
"data": [
{"vehicle_id": "VH-882", "anomalies": 312},
{"vehicle_id": "VH-104", "anomalies": 245},
{"vehicle_id": "VH-902", "anomalies": 189},
{"vehicle_id": "VH-311", "anomalies": 154},
{"vehicle_id": "VH-529", "anomalies": 98}
]
}
[FINAL AGENT SYNTHESIS]
Based on analysis of the fleet sensors dataset over the past 14 days (scanning 65.0 MB), the top 5 vehicles experiencing anomalous thermal events are:
1. **VH-882**: 312 warnings
2. **VH-104**: 245 warnings
3. **VH-902**: 189 warnings
4. **VH-311**: 154 warnings
5. **VH-529**: 98 warnings
π Architectural Evaluation: Direct LLM vs. Governed Lakehouse Agent
| Operational Dimension | Ungoverned Direct LLM Text-to-SQL | Autonomous Governed Lakehouse Agent |
|---|---|---|
| Financial Exposure | Unbounded risk of multi-terabyte scans | Deterministic byte budget gate (<500 MB) |
| Partition Awareness | Hallucinates static date strings | Dynamic Iceberg REST catalog probing |
| AST Security | Susceptible to destructive mutations | Strict token validation via SQLGlot AST |
| Compute Topology | Requires duplicate data marts / pipelines | Direct BigLake vectorized scans over GCS |
| Error Handling | Fatal query crash terminates turn | Autonomous self-correcting partition refinement |
π― Production Engineering Takeaways
- Deterministic Guardrails Outweigh Prompt Engineering: System instructions alone are insufficient to guarantee financial or operational safety. Programmatic gates (AST parsing + zero-cost dry runs) provide mathematically verified boundaries.
- Treat Iceberg Catalogs as Live Agent Memory: Inspecting metadata directly from the catalog removes schema drift and prevents hallucinated column structures.
- Exploit BigQuery Zero-Cost Dry Runs: Using BigQuery's free compilation and byte estimation API creates an ideal reward signal for agent self-correction.
π Official Documentation & Technical References
To implement or extend this architecture, refer to the verified technical documentation:
- Google BigLake & Iceberg Integration:
- Google Gemini & GenAI SDK:
- Apache Iceberg & Open Table Architecture:
- SQL Abstract Syntax Tree (AST) Security:


Top comments (0)