Skill 54 · Ingesting Into Data Lake
Subchapter 54.16
references/jdbc-schema-discovery.mdMarkdown13 KBView on GitHub
Complete guide for discovering schemas in external database systems and inferring target S3 Table schemas.
Schema discovery identifies what data is available in the source system and maps it to Iceberg-compatible types for the target S3 Table.
Gather information about what to import:
Which table(s) or view(s) to import:
CUSTOMERS, SALES_ORDERS, INVENTORYSchema/database name (if system supports multiple databases):
SALES_SCHEMA)SalesDB.dbo)public, analytics)sales_db)Custom SQL query (optional):
SELECT * FROM CUSTOMERS WHERE status = 'ACTIVE' AND created_date >= CURRENT_DATE - 90Use Glue crawlers to discover available tables in the source database.
# Create crawler
aws glue create-crawler \
--name "temp-discovery-crawler" \
--role "<glue-service-role-arn>" \
--database-name "temp_discovery_db" \
--targets '{
"JdbcTargets": [{
"ConnectionName": "<connection-name>",
"Path": "<database>/%"
}]
}' \
--region <region>| Database | Path Pattern | Example |
|---|---|---|
| Oracle | <schema>/% | SALES_SCHEMA/% |
| SQL Server | <database>/<schema>/% | SalesDB/dbo/% |
| PostgreSQL | <database>/<schema>/% | analytics/public/% |
| MySQL | <database>/% | sales_db/% |
# Start crawler
aws glue start-crawler --name "temp-discovery-crawler" --region <region>
# Check status (wait until State is READY)
aws glue get-crawler --name "temp-discovery-crawler" --region <region> \
--query 'Crawler.State' --output textCrawler states: READY (not running), RUNNING, STOPPING
After the crawler completes:
aws glue get-tables --database-name "temp_discovery_db" --region <region>Present the list to the user:
I found these tables in your database:
1. CUSTOMERS (45 columns, ~1.2M rows)
2. ORDERS (32 columns, ~5.8M rows)
3. PRODUCTS (18 columns, ~15K rows)
4. INVENTORY (12 columns, ~250K rows)
Which one(s) should I import?After user selects table(s), clean up the temporary crawler and database:
# Delete crawler
aws glue delete-crawler --name "temp-discovery-crawler" --region <region>
# Delete temp database (optional - tables still useful for reference)
aws glue delete-database --name "temp_discovery_db" --region <region>Once the user identifies the source table, retrieve its detailed schema.
aws glue get-table \
--database-name "temp_discovery_db" \
--name "<table-name>" \
--region <region>This returns:
Alternative: Query the source database directly to get schema.
For SQL databases (via test Glue job):
# test-schema.py
from pyspark.sql import SparkSession
spark = SparkSession.builder.getOrCreate()
# Read just the schema (LIMIT 0)
df = spark.read.format("jdbc").options(
url="<jdbc-url>",
dbtable="(SELECT * FROM <table> WHERE 1=0) AS schema_query",
user="<username>",
password="<password>"
).load()
# Print schema
df.printSchema()Using Athena Federated Query (if connector installed):
-- Query external database via Athena connector
SELECT * FROM "<athena-connector>"."<schema>"."<table>" LIMIT 0Shows schema without transferring data.
If the user wants to filter or transform at source, support custom SQL:
Filter by date:
SELECT *
FROM CUSTOMERS
WHERE created_date >= CURRENT_DATE - 90Filter by status:
SELECT *
FROM ORDERS
WHERE status IN ('COMPLETED', 'SHIPPED')Join multiple tables:
SELECT
o.order_id,
o.order_date,
c.customer_name,
c.email,
SUM(oi.quantity * oi.price) as total_amount
FROM ORDERS o
JOIN CUSTOMERS c ON o.customer_id = c.customer_id
JOIN ORDER_ITEMS oi ON o.order_id = oi.order_id
WHERE o.order_date >= CURRENT_DATE - 30
GROUP BY o.order_id, o.order_date, c.customer_name, c.emailSelect specific columns:
SELECT
customer_id,
customer_name,
email,
phone,
created_date,
last_purchase_date
FROM CUSTOMERS
WHERE status = 'ACTIVE'Store the query for use in the Glue ETL script:
Option 1: As job parameter:
'--source_query': 'SELECT * FROM CUSTOMERS WHERE status = \'ACTIVE\''Option 2: In S3 file:
# Read query from S3
import boto3
s3 = boto3.client('s3')
obj = s3.get_object(Bucket='<bucket>', Key='queries/customer-import.sql')
source_query = obj['Body'].read().decode('utf-8')Map source database types to Iceberg types for the target S3 Table.
| Source Type | Iceberg Type | Notes |
|---|---|---|
| VARCHAR, CHAR, TEXT, STRING | STRING | Variable-length text |
| INTEGER, INT, SMALLINT | INTEGER | 32-bit signed integer |
| BIGINT, NUMBER(19) | BIGINT | 64-bit signed integer |
| DECIMAL, NUMERIC | DECIMAL(p,s) | Preserve precision/scale |
| FLOAT, REAL | FLOAT | 32-bit floating point |
| DOUBLE PRECISION, BINARY_DOUBLE | DOUBLE | 64-bit floating point |
| BOOLEAN, BIT | BOOLEAN | True/false |
| DATE | DATE | Date without time |
| TIMESTAMP, DATETIME, DATETIME2 | TIMESTAMP | Date and time |
| TIME | STRING | Convert to string (Iceberg has no TIME type) |
| BLOB, BYTEA, VARBINARY, BINARY | BINARY | Binary data |
| CLOB, TEXT | STRING | Large text |
| UUID | STRING | Store as string |
| JSON, JSONB | STRING | Store as JSON string |
| ARRAY | ARRAY<T> | If database supports arrays |
| MAP, HSTORE | MAP<K,V> | If database supports maps |
| Oracle Type | Iceberg Type | Notes |
|---|---|---|
| NUMBER | DECIMAL or BIGINT | Use DECIMAL for precision, BIGINT if no scale |
| NUMBER(p,s) | DECIMAL(p,s) | Preserve precision and scale |
| VARCHAR2, NVARCHAR2 | STRING | Variable-length string |
| CHAR, NCHAR | STRING | Fixed-length string |
| DATE | TIMESTAMP | Oracle DATE includes time |
| TIMESTAMP | TIMESTAMP | Direct mapping |
| CLOB, NCLOB | STRING | Large text |
| BLOB | BINARY | Binary data |
| RAW | BINARY | Raw binary |
| SQL Server Type | Iceberg Type | Notes |
|---|---|---|
| NVARCHAR, VARCHAR | STRING | Unicode or ASCII string |
| INT | INTEGER | 32-bit integer |
| BIGINT | BIGINT | 64-bit integer |
| SMALLINT | INTEGER | 16-bit → 32-bit |
| TINYINT | INTEGER | 8-bit → 32-bit |
| DECIMAL, NUMERIC | DECIMAL(p,s) | Preserve precision |
| MONEY, SMALLMONEY | DECIMAL(19,4) | Currency |
| DATETIME, DATETIME2 | TIMESTAMP | Date and time |
| DATE | DATE | Date only |
| TIME | STRING | Convert to string |
| BIT | BOOLEAN | True/false |
| UNIQUEIDENTIFIER | STRING | GUID as string |
| PostgreSQL Type | Iceberg Type | Notes |
|---|---|---|
| INTEGER | INTEGER | 32-bit integer |
| BIGINT | BIGINT | 64-bit integer |
| SMALLINT | INTEGER | 16-bit → 32-bit |
| NUMERIC | DECIMAL(p,s) | Arbitrary precision |
| REAL | FLOAT | 32-bit floating |
| DOUBLE PRECISION | DOUBLE | 64-bit floating |
| TEXT, VARCHAR | STRING | Variable-length text |
| BOOLEAN | BOOLEAN | True/false |
| DATE | DATE | Date only |
| TIMESTAMP | TIMESTAMP | Date and time |
| TIMESTAMPTZ | TIMESTAMP | Timestamp with timezone |
| JSON, JSONB | STRING | Store as JSON string |
| UUID | STRING | UUID as string |
| BYTEA | BINARY | Binary data |
| ARRAY | ARRAY<T> | PostgreSQL arrays supported |
| MySQL Type | Iceberg Type | Notes |
|---|---|---|
| INT, INTEGER | INTEGER | 32-bit integer |
| BIGINT | BIGINT | 64-bit integer |
| SMALLINT | INTEGER | 16-bit → 32-bit |
| TINYINT | INTEGER | 8-bit → 32-bit (or BOOLEAN if TINYINT(1)) |
| DECIMAL, NUMERIC | DECIMAL(p,s) | Preserve precision |
| FLOAT | FLOAT | 32-bit floating |
| DOUBLE | DOUBLE | 64-bit floating |
| VARCHAR, CHAR, TEXT | STRING | Variable/fixed-length text |
| DATE | DATE | Date only |
| DATETIME, TIMESTAMP | TIMESTAMP | Date and time |
| TIME | STRING | Convert to string |
| BOOLEAN | BOOLEAN | True/false |
| BLOB, BINARY, VARBINARY | BINARY | Binary data |
| JSON | STRING | Store as JSON string |
Based on the source schema, propose the target S3 Table schema to the user.
Source table: CUSTOMERS in Oracle database
Proposed S3 Table schema:
CREATE TABLE "glue_catalog"."sales"."customers" (
customer_id BIGINT,
customer_name STRING,
email STRING,
phone STRING,
status STRING,
credit_limit DECIMAL(10,2),
created_date TIMESTAMP,
last_purchase_date TIMESTAMP,
load_timestamp TIMESTAMP, -- Added: track when record was loaded
load_date DATE -- Partition column for efficient queries
)
PARTITIONED BY (load_date)
TBLPROPERTIES ('table_type' = 'ICEBERG')Present to user:
I've mapped the Oracle CUSTOMERS table to this Iceberg schema:
Source (Oracle) → Target (Iceberg)
CUSTOMER_ID (NUMBER(10)) → customer_id (BIGINT)
CUSTOMER_NAME (VARCHAR2(200)) → customer_name (STRING)
EMAIL (VARCHAR2(255)) → email (STRING)
PHONE (VARCHAR2(20)) → phone (STRING)
STATUS (VARCHAR2(20)) → status (STRING)
CREDIT_LIMIT (NUMBER(10,2)) → credit_limit (DECIMAL(10,2))
CREATED_DATE (DATE) → created_date (TIMESTAMP)
LAST_PURCHASE_DATE (DATE) → last_purchase_date (TIMESTAMP)
Added columns:
- load_timestamp (TIMESTAMP): Tracks when record was loaded
- load_date (DATE): Partition column for efficient queries
Does this look correct? Any adjustments needed?Option 1: Store as STRING (simplest)
json_data STRINGOption 2: Parse and flatten to STRUCT
metadata STRUCT<
key1: STRING,
key2: INT,
key3: ARRAY<STRING>
>Recommend Option 1 unless user specifically wants to query nested fields.
If source database supports arrays (PostgreSQL, Oracle VARRAY):
Option 1: Keep as ARRAY
tags ARRAY<STRING>Option 2: Convert to STRING (comma-separated)
tags STRING -- "tag1,tag2,tag3"Option 3: Explode to separate table (normalized)
Recommendation: Only import if truly needed (increases storage costs)
If importing:
document BINARYConsider storing large binaries in S3 and storing S3 key in table instead:
document_s3_key STRING -- "s3://docs-bucket/doc123.pdf"load_timestamp, load_date, source_systemSchema discovery workflow:
With proper schema discovery, data from external databases maps cleanly to S3 Tables with Iceberg types.