Skill 54 · Ingesting Into Data Lake
Subchapter 54.13
references/incremental-loading.mdMarkdown13 KBView on GitHub
Complete guide for configuring incremental data loading from external databases.
Incremental loading imports only new or changed records instead of the entire dataset on each run. This is essential for recurring pipelines to minimize data transfer and processing time.
A watermark column tracks which records have been loaded. The Glue job queries for records where watermark > last_loaded_value.
Timestamp column (preferred):
updated_at, modified_date, last_changed, etl_timestampWHERE timestamp_col > '2024-03-12 10:30:00'Monotonic ID column:
id, order_id, transaction_id (auto-incrementing)WHERE id > 1234567Both timestamp and ID:
WHERE timestamp_col > '...' OR (timestamp_col IS NULL AND id > ...)Present candidates from the source schema:
I found these potential watermark columns:
1. CREATED_DATE (TIMESTAMP) - Never changes once set
2. UPDATED_AT (TIMESTAMP) - Updates when record changes (recommended)
3. ID (NUMBER) - Auto-incrementing primary key
Which should I use to track new/updated records?Recommendation logic:
updated_at or modified_date exists → Recommend this (captures updates)Best for: Immutable data
How it works:
watermark > last_watermarkPros: Simple, fast, no deduplication needed Cons: Doesn’t capture updates to existing records
PySpark example:
# Filter for new records
new_records_df = source_df.filter(
f"{watermark_column} > '{last_watermark}'"
)
# Append to target
new_records_df.writeTo(target_table).append()Best for: Mutable data
How it works:
watermark > last_watermarkPros: Captures both new records and updates Cons: More complex, requires MERGE operation
PySpark example:
# Get new/updated records
changed_records_df = source_df.filter(
f"{watermark_column} > '{last_watermark}'"
)
# Merge into target (upsert)
spark.sql(f"""
MERGE INTO {target_table} AS target
USING changed_records AS source
ON target.{primary_key} = source.{primary_key}
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *
""")Best for:
How it works:
Pros: Simple, guarantees data consistency Cons: Inefficient for large tables, higher data transfer costs
PySpark example:
# Read all records
all_records_df = source_df.select("*")
# Overwrite target table
all_records_df.writeTo(target_table).overwritePartitions()The Glue job needs to persist the last loaded watermark value between runs.
Store watermark in a text file in S3.
Advantages:
Implementation:
import boto3
s3 = boto3.client('s3')
watermark_bucket = args['watermark_bucket']
watermark_key = args['watermark_key']
# Read last watermark
try:
obj = s3.get_object(Bucket=watermark_bucket, Key=watermark_key)
last_watermark = obj['Body'].read().decode('utf-8').strip()
print(f"Last watermark: {last_watermark}")
except s3.exceptions.NoSuchKey:
last_watermark = '1970-01-01 00:00:00' # Default for timestamp
# OR last_watermark = '0' # Default for ID
print("No previous watermark found, starting from beginning")
# After loading, update watermark
new_watermark = filtered_df.agg({watermark_column: "max"}).collect()[0][0]
s3.put_object(
Bucket=watermark_bucket,
Key=watermark_key,
Body=str(new_watermark)
)
print(f"Updated watermark to: {new_watermark}")S3 path structure:
s3://my-glue-watermarks/
customers.txt → "2024-03-12 14:30:00"
orders.txt → "2024-03-12 14:25:00"
products.txt → "2024-03-10 08:00:00"Store watermarks in a DynamoDB table with one item per job.
Advantages:
Create table:
aws dynamodb create-table \
--table-name glue-job-watermarks \
--attribute-definitions \
AttributeName=job_name,AttributeType=S \
--key-schema \
AttributeName=job_name,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region <region>Implementation:
import boto3
from datetime import datetime
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('glue-job-watermarks')
job_name = args['JOB_NAME']
# Read last watermark
try:
response = table.get_item(Key={'job_name': job_name})
item = response['Item']
last_watermark = item['watermark']
print(f"Last watermark for {job_name}: {last_watermark}")
except KeyError:
last_watermark = '1970-01-01 00:00:00'
print("No previous watermark found, starting from beginning")
# After loading, update watermark
new_watermark = filtered_df.agg({watermark_column: "max"}).collect()[0][0]
table.put_item(Item={
'job_name': job_name,
'watermark': str(new_watermark),
'last_run_time': datetime.now().isoformat(),
'rows_loaded': row_count
})
print(f"Updated watermark to: {new_watermark}")Query the target S3 Table to determine the max watermark value.
Advantages:
Disadvantages:
Implementation:
# Query target table for max watermark
try:
max_watermark_df = spark.sql(f"""
SELECT MAX({watermark_column}) as max_value
FROM {target_table}
""")
last_watermark = max_watermark_df.collect()[0]['max_value']
if last_watermark is None:
last_watermark = '1970-01-01 00:00:00'
print(f"Max watermark in target: {last_watermark}")
except:
last_watermark = '1970-01-01 00:00:00'
print("Target table empty or doesn't exist, starting from beginning")Recommendation: Use Option A (S3 file) for simplicity unless you have specific requirements for DynamoDB’s features.
Problem: Source database uses one timezone, target uses another Solution: Normalize all timestamps to UTC
from pyspark.sql.functions import to_utc_timestamp
# Convert source timestamp to UTC
df_utc = source_df.withColumn(
"timestamp_utc",
to_utc_timestamp(col("source_timestamp"), "America/New_York")
)Scenario: Need to load historical data before starting incremental loads
Approach:
1900-01-01 00:00:00OR load in batches:
# Batch 1: Load 2020 data
WHERE timestamp >= '2020-01-01' AND timestamp < '2021-01-01'
# Batch 2: Load 2021 data
WHERE timestamp >= '2021-01-01' AND timestamp < '2022-01-01'
# Batch 3: Load 2022+ data
WHERE timestamp >= '2022-01-01'
# Then switch to incrementalProblem: Records arrive after their timestamp (e.g., event from yesterday arrives today)
Solution 1: Add buffer window
# Load data from 1 day before last watermark to catch late arrivals
buffer_watermark = last_watermark - timedelta(days=1)
WHERE timestamp > buffer_watermarkSolution 2: Use separate updated_at column
# Use updated_at instead of event_timestamp
WHERE updated_at > last_watermarkProblem: Source deletes records, but incremental load doesn’t capture deletions
Solutions:
Option 1: Periodic full refresh
Option 2: Soft deletes
WHERE updated_at > last_watermark OR deleted_at > last_watermarkOption 3: Compare and prune
Problem: Same record loaded multiple times due to job retries or watermark issues
Prevention:
from pyspark.sql.window import Window
from pyspark.sql.functions import row_number
# Deduplicate by primary key, keeping latest by watermark
window = Window.partitionBy("primary_key").orderBy(col(watermark_column).desc())
deduplicated_df = df.withColumn("row_num", row_number().over(window)) \
.filter(col("row_num") == 1) \
.drop("row_num")Ensure the watermark column has an index in the source database:
-- Oracle
CREATE INDEX idx_customers_updated_at ON CUSTOMERS(UPDATED_AT);
-- SQL Server
CREATE INDEX idx_customers_updated_at ON CUSTOMERS(UPDATED_AT);
-- PostgreSQL
CREATE INDEX idx_customers_updated_at ON customers(updated_at);Without an index, source database will do full table scans.
For high-volume tables, load data in smaller batches:
# Load 1 hour of data at a time
batch_size = timedelta(hours=1)
current_watermark = last_watermark
while current_watermark < datetime.now():
next_watermark = current_watermark + batch_size
batch_df = source_df.filter(
(col(watermark_column) > current_watermark) &
(col(watermark_column) <= next_watermark)
)
batch_df.writeTo(target_table).append()
current_watermark = next_watermarkUse Spark’s partitioning for parallel reads from source:
source_df = spark.read.format("jdbc").options(
url=jdbc_url,
dbtable=table_name,
numPartitions=10, # Read in parallel with 10 partitions
partitionColumn=watermark_column,
lowerBound=last_watermark,
upperBound=current_time
).load()Track these metrics for each incremental load:
# Log metrics
print(f"Job metrics:")
print(f" Rows loaded: {row_count}")
print(f" Previous watermark: {last_watermark}")
print(f" New watermark: {new_watermark}")
print(f" Watermark advancement: {new_watermark - last_watermark}")
print(f" Load duration: {load_duration} seconds")
# Publish to CloudWatch (optional)
cloudwatch = boto3.client('cloudwatch')
cloudwatch.put_metric_data(
Namespace='GlueJobs',
MetricData=[{
'MetricName': 'RowsLoaded',
'Value': row_count,
'Unit': 'Count',
'Dimensions': [{'Name': 'JobName', 'Value': job_name}]
}]
)updated_at over created_at for mutable dataIncremental loading workflow:
With proper incremental loading, recurring pipelines efficiently sync only changed data from external databases.