13 KiB
Incremental Loading Strategies
Complete guide for configuring incremental data loading from external databases.
Overview
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.
Identify Watermark Column
A watermark column tracks which records have been loaded. The Glue job queries for records where watermark > last_loaded_value.
Common Watermark Patterns
Timestamp column (preferred):
updated_at,modified_date,last_changed,etl_timestamp- Query:
WHERE timestamp_col > '2024-03-12 10:30:00' - Best for: Mutable data that gets updated
Monotonic ID column:
id,order_id,transaction_id(auto-incrementing)- Query:
WHERE id > 1234567 - Best for: Immutable data with sequential IDs
Both timestamp and ID:
- Use timestamp for recent changes, ID as fallback for historical data
- Query:
WHERE timestamp_col > '...' OR (timestamp_col IS NULL AND id > ...)
Ask the User
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:
- If
updated_atormodified_dateexists → Recommend this (captures updates) - Else if timestamp column exists → Use creation timestamp
- Else if auto-incrementing ID → Use ID
- Else → Recommend full refresh
Determine Load Strategy
Incremental Append (New Records Only)
Best for: Immutable data
- Transaction logs
- Event streams
- Historical orders
- Audit trails
How it works:
- Query source for records where
watermark > last_watermark - Append new records to target table
- Update watermark to max value from current batch
Pros: 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()
Incremental Upsert (New + Updated Records)
Best for: Mutable data
- Customer profiles
- Product catalogs
- Employee records
- Account balances
How it works:
- Query source for records where
watermark > last_watermark - Merge into target table using primary key
- Update existing records, insert new ones
- Update watermark
Pros: 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 *
""")
Full Refresh
Best for:
- Small dimension tables (< 10K rows)
- Data without watermark columns
- When source doesn't support incremental queries
How it works:
- Truncate or drop target table
- Load all records from source
- No watermark needed
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()
Watermark Storage Options
The Glue job needs to persist the last loaded watermark value between runs.
Option A: S3 File (Simple)
Store watermark in a text file in S3.
Advantages:
- Simple to implement
- No additional AWS services
- Easy to inspect and debug
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"
Option B: DynamoDB Table (Robust)
Store watermarks in a DynamoDB table with one item per job.
Advantages:
- Atomic updates
- Query watermarks programmatically
- Can store additional metadata (last run time, row count, etc.)
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}")
Option C: Query Target Table (Advanced)
Query the target S3 Table to determine the max watermark value.
Advantages:
- No external storage needed
- Watermark always matches actual data
Disadvantages:
- Requires target table scan (can be slow)
- Doesn't work for first run (empty table)
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.
Handling Edge Cases
Timezone Considerations
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")
)
Backfill Historical Data
Scenario: Need to load historical data before starting incremental loads
Approach:
- Set watermark to earliest desired date:
1900-01-01 00:00:00 - Run job once to load all historical data
- Subsequent runs will be incremental from that point forward
OR 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 incremental
Late-Arriving Data
Problem: 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_watermark
Solution 2: Use separate updated_at column
# Use updated_at instead of event_timestamp
WHERE updated_at > last_watermark
Deleted Records
Problem: Source deletes records, but incremental load doesn't capture deletions
Solutions:
Option 1: Periodic full refresh
- Run incremental loads daily
- Run full refresh weekly to remove deleted records
Option 2: Soft deletes
- Source system marks records as deleted instead of removing them
- Filter:
WHERE updated_at > last_watermark OR deleted_at > last_watermark
Option 3: Compare and prune
- Periodically query source for all IDs
- Find IDs in target that don't exist in source
- Delete those records from target
Duplicate Records
Problem: Same record loaded multiple times due to job retries or watermark issues
Prevention:
- Use upsert instead of append for mutable data
- Add deduplication logic:
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")
Performance Optimization
Index Watermark Column
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.
Batch Size Tuning
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_watermark
Parallel Reads
Use 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()
Monitoring and Alerting
Track these metrics for each incremental load:
- Rows loaded: Number of new/updated records
- Watermark advancement: How much watermark advanced
- Load duration: Time taken for the job
- Data lag: Difference between source max watermark and loaded watermark
# 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}]
}]
)
Best Practices
- Choose the right watermark column: Prefer
updated_atovercreated_atfor mutable data - Test with small batches first: Verify logic before full-scale loads
- Add buffer for late arrivals: Consider loading data from 1 day before watermark
- Monitor watermark advancement: Alert if watermark stops advancing
- Handle timezones consistently: Convert all timestamps to UTC
- Index watermark column in source: Dramatically improves query performance
- Use upsert for mutable data: Prevents duplicates and captures updates
- Store watermark reliably: S3 file is simple and sufficient for most cases
Summary
Incremental loading workflow:
- Identify watermark column - Timestamp or auto-incrementing ID
- Choose load strategy - Append (immutable) vs Upsert (mutable) vs Full Refresh
- Store watermark - S3 file, DynamoDB, or query target table
- Handle edge cases - Timezones, late arrivals, deletions, duplicates
- Optimize performance - Index watermark, batch loading, parallel reads
- Monitor - Track rows loaded, watermark advancement, data lag
With proper incremental loading, recurring pipelines efficiently sync only changed data from external databases.