Data Ingestion Patterns — Full Load, Incremental, CDC
The three patterns that cover every source — when each is correct, how each fails, and how to choose.
Every Ingestion Problem Falls Into One of Three Patterns
A data engineer’s first job with any new source system is answering one question: how do I get data out of this reliably, completely, and without harming it? The answer is almost always a variant of one of three ingestion patterns.
The three patterns exist on a spectrum from simple-but-expensive to complex-but-efficient. This module builds all three around FreshCart’s actual table inventory — reference data, the orders table, and the tables where a missed delete is a real problem.
Full Load — Read Everything, Every Time
Every run reads the complete source table and replaces the destination’s content entirely. No watermarks, no change tracking. For small tables that change frequently in hard-to-track ways, this is often the correct and permanent choice.
Two implementation variants
BEGIN;
TRUNCATE TABLE silver.store_master;
INSERT INTO silver.store_master
SELECT store_id, store_name, city, region, is_active, manager_id FROM source.stores;
COMMIT;
-- other queries see either all-old or all-new, never empty (MVCC) — but only
-- while this single transaction is what they're reading againstCREATE TABLE silver.store_master_new AS
SELECT store_id, store_name, city, region, is_active, manager_id FROM source.stores;
BEGIN;
ALTER TABLE silver.store_master RENAME TO store_master_old;
ALTER TABLE silver.store_master_new RENAME TO store_master;
COMMIT;
DROP TABLE silver.store_master_old;
-- during load: store_master_old serves queries. after rename: store_master (new) does.
-- zero seconds where the table is empty or has partial datadef full_load_with_swap(source_conn, dest_conn, table: str) -> int:
df = pd.read_sql(f"SELECT * FROM {table}", source_conn)
staging = f"{table}_staging"
df.to_sql(staging, dest_conn, if_exists='replace', index=False)
with dest_conn.cursor() as cur:
cur.execute(f"ALTER TABLE {table} RENAME TO {table}_old")
cur.execute(f"ALTER TABLE {staging} RENAME TO {table}")
cur.execute(f"DROP TABLE {table}_old")
dest_conn.commit()
return len(df)>>> full_load_with_swap(source_conn, dest_conn, 'store_master')
40
# 40 stores reloaded, zero downtime — analysts querying store_master mid-swap
# saw either the complete old table or the complete new oneWhen full load is genuinely the right choice
When full load breaks down
1. TABLE GROWS TOO LARGE
orders: 500M rows, full load takes 6h, SLA is 6 AM → barely fits.
Signal to switch: full load duration > 20% of the run interval.
2. SOURCE LOAD DURING EXTRACTION
A full table scan fills the buffer pool, evicting hot pages —
the application slows down for 30-60 min afterward.
Fix: extract from a read replica, never the primary.
3. DESTINATION INCONSISTENCY WINDOW
TRUNCATE-then-INSERT (Variant A) leaves the table empty mid-transaction
for any query outside that transaction. Fix: staging swap (Variant B).
4. RELOAD OVERWRITES LATE-ARRIVING CORRECTIONS
A manual data fix in the destination gets silently overwritten by the
next full load. Expected behavior — but teams get surprised by it.
If destination edits must survive: use incremental or CDC instead.Incremental — Only What Changed
A high-watermark column — typically updated_at — tracks progress. A 1-billion-row orders table receiving 100,000 changes a day only requires reading 100,000 rows per run, not 1 billion.
Checkpoint management — load and save, atomically
import json, logging
from datetime import datetime, timezone
from pathlib import Path
log = logging.getLogger('incremental_ingestion')
CHECKPOINT_FILE = Path('/data/checkpoints/orders_watermark.json')
def load_watermark() -> datetime:
if CHECKPOINT_FILE.exists():
wm = datetime.fromisoformat(json.loads(CHECKPOINT_FILE.read_text())['watermark'])
log.info('Loaded watermark: %s', wm.isoformat())
return wm
default = datetime(2020, 1, 1, tzinfo=timezone.utc)
log.info('No checkpoint found — starting from %s', default.isoformat())
return default
def save_watermark(watermark: datetime) -> None:
tmp = CHECKPOINT_FILE.with_suffix('.tmp')
tmp.write_text(json.dumps({'watermark': watermark.isoformat()}))
tmp.rename(CHECKPOINT_FILE) # atomic on POSIXExtraction and loading
def extract_changed_orders(conn, since: datetime, until: datetime) -> pd.DataFrame:
"""since is exclusive, until is inclusive — no boundary row re-processed or skipped."""
df = pd.read_sql("""
SELECT order_id, customer_id, store_id, order_amount, status, created_at, updated_at
FROM orders WHERE updated_at > %s AND updated_at <= %s ORDER BY updated_at ASC
""", conn, params=(since, until))
log.info('Extracted %d rows (updated %s to %s)', len(df), since.isoformat(), until.isoformat())
return df
def upsert_orders(df: pd.DataFrame, dest_conn) -> int:
if df.empty:
return 0
with dest_conn.cursor() as cur:
for _, row in df.iterrows():
cur.execute("""
INSERT INTO silver.orders (order_id, customer_id, store_id, order_amount, status, created_at, updated_at, ingested_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (order_id) DO UPDATE SET status = EXCLUDED.status, updated_at = EXCLUDED.updated_at, ingested_at = NOW()
WHERE silver.orders.updated_at < EXCLUDED.updated_at
""", (row.order_id, row.customer_id, row.store_id, row.order_amount, row.status, row.created_at, row.updated_at))
dest_conn.commit()
return len(df)Wiring it together
def run_incremental(source_conn, dest_conn) -> dict:
since = load_watermark()
until = pd.read_sql("SELECT NOW() AT TIME ZONE 'UTC'", source_conn).iloc[0, 0].to_pydatetime()
df = extract_changed_orders(source_conn, since, until)
if df.empty:
return {'rows_processed': 0, 'new_watermark': since.isoformat()}
written = upsert_orders(df, dest_conn)
save_watermark(until) # only AFTER the write succeeded
return {'rows_processed': written, 'new_watermark': until.isoformat()}INFO Loaded watermark: 2026-03-17T05:45:00+00:00
INFO Extracted 1,842 rows (updated 2026-03-17T05:45:00+00:00 to 2026-03-17T06:00:00+00:00)
>>> run_incremental(source_conn, dest_conn)
{'rows_processed': 1842, 'new_watermark': '2026-03-17T06:00:00+00:00'}The four pitfalls that break incremental in production
# PITFALL 1: HARD DELETES ARE INVISIBLE
# A deleted row produces no result from 'WHERE updated_at > checkpoint' —
# there's nothing left to return. Destination silently diverges from source.
# Fix A: use CDC (captures DELETE explicitly)
# Fix B: soft-delete column (deleted_at / is_deleted) — updates updated_at, so it's seen
# Fix C: periodic full-load reconciliation (weekly) if deletes are rare
# PITFALL 2: NO updated_at COLUMN
# Fix A: use max(primary_key) as watermark — ONLY safe if rows are insert-only
# Fix B: CDC (doesn't depend on an application-maintained timestamp)
# Fix C: full load, if the table is small enough# PITFALL 3: CLOCK SKEW BETWEEN SOURCE AND PIPELINE SERVER
# pipeline clock 06:00:00, source clock 06:00:02 (2s ahead) —
# a row inserted at 06:00:01 on the source's clock looks like "the future" and gets excluded
# Fix: always use the SOURCE database's NOW() as the upper bound, never the pipeline server's
# PITFALL 4: LATE-ARRIVING UPDATES
# row.updated_at = 11:58:00, but it doesn't actually reach the source table until
# 12:03:00 (a delayed application retry) — by then the checkpoint has already moved past 12:00:00
# Fix: extend the LOWER bound back by a safe margin (e.g. 30 min) and rely on
# upsert to make the resulting re-processed overlap rows harmlessWatermark column selection — the decision matters
| Watermark type | How to query | Works for updates? | Works for deletes? | Notes |
|---|---|---|---|---|
| updated_at (TIMESTAMPTZ) | WHERE updated_at > checkpoint | ✓ Yes | ✗ No | Best option. Requires the application to maintain updated_at correctly. |
| created_at only | WHERE created_at > checkpoint | ✗ No | ✗ No | Only correct for append-only tables (logs, events, immutable facts). |
| Auto-increment PK | WHERE order_id > max_id | ✗ No | ✗ No | Only for insert-only tables. Breaks if rows insert out of ID order. |
| None — use CDC | Read WAL directly | ✓ Yes | ✓ Yes | When no reliable timestamp exists. Most complete, most complex. |
Change Data Capture — The Complete Picture
CDC reads the database’s own transaction log — the Write-Ahead Log in PostgreSQL — and converts every insert, update, and delete into a structured event. This captures what no query-based approach can: hard deletes, multi-table transactions, and changes faster than any polling interval.
From a database operation to a Kafka message
-- Application writes:
UPDATE orders SET status = 'delivered' WHERE order_id = 9284751;
-- PostgreSQL WAL records (simplified):
-- {LSN: 0/1A3F2B8, op: UPDATE, table: orders,
-- old: {order_id: 9284751, status: 'confirmed'}, new: {..., status: 'delivered'}}
-- Debezium decodes the WAL and publishes to Kafka topic 'prod.public.orders':
{
"before": {"order_id": 9284751, "status": "confirmed"},
"after": {"order_id": 9284751, "status": "delivered"},
"op": "u", // c=create, u=update, d=delete, r=read/snapshot
"source": {"lsn": 28437128, "txId": 847291}
}
-- A DELETE looks like: {"before": {...}, "after": null, "op": "d"}CDC captures everything:
✓ INSERT → op: "c" ✓ UPDATE → op: "u" (before+after) ✓ DELETE → op: "d" (before image)
✓ Schema changes (with schema registry) ✓ Transaction boundaries (atomic groups)Setting up Debezium on PostgreSQL
# postgresql.conf — must restart PostgreSQL after this
wal_level = logical
max_replication_slots = 10
max_wal_senders = 10
CREATE USER debezium_user REPLICATION LOGIN PASSWORD 'strong_password';
GRANT SELECT ON ALL TABLES IN SCHEMA public TO debezium_user;
SELECT pg_create_logical_replication_slot('debezium_slot', 'pgoutput');// POST http://kafka-connect:8083/connectors
{
"name": "freshcart-orders-cdc",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres-primary",
"database.dbname": "freshcart_prod",
"table.include.list": "public.orders,public.customers,public.payments",
"plugin.name": "pgoutput",
"slot.name": "debezium_slot",
"snapshot.mode": "initial",
"topic.prefix": "freshcart.cdc"
}
}
// creates Kafka topics: freshcart.cdc.public.{orders,customers,payments}consumer = Consumer({'bootstrap.servers': 'kafka:9092', 'group.id': 'freshcart-cdc-pipeline',
'enable.auto.commit': False}) # manual commit — at-least-once
consumer.subscribe(['freshcart.cdc.public.orders'])
while True:
msg = consumer.poll(timeout=1.0)
if msg is None or msg.error():
continue
event = json.loads(msg.value())
if event['op'] in ('c', 'u', 'r'):
upsert_to_silver(event['after'])
elif event['op'] == 'd':
soft_delete_in_silver(event['before']['order_id'])
consumer.commit() # only after the write succeedsThe initial snapshot — bootstrapping a large table
The first time CDC starts, it needs the existing data too, not just future changes. snapshot.mode: initial reads the entire table as "r" events before switching to streaming — but for 500M rows that snapshot alone can take 8+ hours.
# snapshot.mode options: initial (default, full read then stream) | never (stream only,
# misses everything before connector start) | schema_only (schema only, no data) | always (dev only)
# PRACTICAL BOOTSTRAP for a 500M-row table:
# 1. pg_dump → S3 (parallel, 1-2 hours)
# 2. Bulk load the S3 dump into the destination
# 3. Start Debezium with snapshot.mode=schema_only, from the WAL LSN at dump time
# 4. Apply WAL events from that LSN forward — catches up during/after the bulk load
# → reduces bootstrap from 8 hours to ~2 hoursOperational concerns every DE must know
-- A stuck consumer means WAL accumulates on the SOURCE forever until it's read.
-- Monitor:
SELECT slot_name, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) AS lag_bytes
FROM pg_replication_slots;
-- Alert when lag_bytes > 10 GB. If the consumer is unrecoverable: DROP the slot
-- (accepting data loss) rather than let the source disk fill and crash the database.CDC LATENCY (Debezium + Kafka + consumer), end to end:
Source write → Kafka event: 50-200ms
Kafka event → consumer processing: 10-100ms
Consumer → destination write: 50-500ms
Total: 200ms - 1s — fine for near-real-time dashboards, NOT for synchronous app flowFull Load vs Incremental vs CDC — Every Dimension
| Dimension | Full Load | Incremental | CDC |
|---|---|---|---|
| What is read | Every row, every run | Only rows with updated_at > checkpoint | Every database operation from WAL |
| Captures hard deletes | ✓ Yes (row absent after reload) | ✗ No (invisible to query) | ✓ Yes (op: d, with before image) |
| Source load | Full table scan every run — high | Index scan on watermark — low | WAL streaming — minimal (async) |
| Latency | Run interval | Run interval | Near-real-time (seconds) |
| Before image available | ✗ No | ✗ No | ✓ Yes — previous values |
| Complexity | Low | Medium | High |
| Requires source config | No | No | Yes — wal_level=logical, replication slot |
| Recovery from failure | Re-run full load | Re-run from checkpoint | Resume from last Kafka offset |
| Best for | Small tables, reference data | Large append-heavy tables | Deletes, financial data, low latency |
How to Choose the Right Pattern for Any Source Table
The choice is never arbitrary — it’s determined by the source table’s characteristics. Answer these four questions in order and the right pattern becomes clear.
1. Row count and growth rate?
< 1M rows, grows slowly → Full Load is viable
> 1M rows or grows fast → Incremental or CDC required
2. Reliable updated_at column?
Yes → Incremental is viable, continue to Q3
No, insert-only → use created_at or auto-increment PK
No, has updates/deletes → CDC or Full Load only
3. Do hard deletes matter for the destination?
No (rare, or soft-deleted) → Incremental is sufficient
Yes → CDC required — incremental cannot see hard deletes
4. Latency requirement?
> 15 min acceptable → Incremental on a schedule
< 15 min → CDC, or 5-minute micro-batch incremental
< 1 min → CDC onlyproduct_categories (500 rows, rarely changes) → Full Load
orders (500M rows, updated frequently) → Incremental
customers (10M rows, hard deletes for GDPR) → CDC
payment_transactions (1B rows, financial accuracy critical) → CDC
delivery_events (append-only, no deletes) → Incremental (created_at)
inventory (updates + deletes frequently) → CDCMost production platforms use all three at once
FULL LOAD (nightly, 5 min total):
reference.store_master, reference.product_categories, reference.city_tier_mapping
INCREMENTAL (every 15 min, updated_at watermark):
orders (500M rows), delivery_events (2B rows, created_at), customer_reviews (created_at)
CDC (continuous, sub-second latency):
customers (GDPR deletes), payments (financial), merchant_accounts, inventory_live
TOTAL INFRASTRUCTURE:
Full load: 2 cron jobs. Incremental: 3 Airflow tasks.
CDC: 1 Debezium connector, 4 Kafka topics, 1 consumer group.
→ Most data volume is incremental. Most operational complexity is CDC — for only 4 tables.Five Misconceptions About Ingestion Patterns
Diagnosing Missing Data — Tracing It to the Ingestion Pattern
The customer success team reports that cancelled orders are still showing up as “active” on the dashboard. Orders customers cancelled yesterday appear as “placed” in the Silver layer.
SELECT order_id, status, updated_at FROM production.orders WHERE order_id = 9284751;
-- {status: 'cancelled', updated_at: '2026-03-17 14:32:00'}
SELECT order_id, status, updated_at FROM silver.orders WHERE order_id = 9284751;
-- {status: 'placed', updated_at: '2026-03-17 08:14:00'} ← 6-hour gap
-- checkpoint file: {"watermark": "2026-03-17T08:00:00+00:00"} — hasn't moved in 6 hours
$ tail -100 /var/log/airflow/orders_incremental_20260317.log | grep ERROR
08:15:42 ERROR Connection to source database timed out
08:15:42 ERROR Pipeline failed — checkpoint NOT advanced
14:00:00 INFO Database connection restored14:00:02 INFO Loaded watermark: 2026-03-17T08:00:00+00:00
14:00:03 INFO Extracted 284,721 rows (updated 08:00 to 14:00)
14:00:47 INFO 284,721 rows upserted successfully
14:00:47 INFO Saved watermark: 2026-03-17T14:00:00+00:00
SELECT status FROM silver.orders WHERE order_id = 9284751;
-- 'cancelled' ← correct nowThis was not a bug in the ingestion pattern — it was a 6-hour source database outage. The incremental pattern with checkpointing recovered perfectly: it resumed exactly where it stopped, processed the backlog, and Silver was correct within minutes of the database recovering. A full load would have needed a full 6-hour table scan to recover the same ground; CDC would have needed Kafka retention to have covered the whole 6-hour gap. Incremental just needed its next scheduled run.
5 Interview Questions — With Complete Answers
Mistakes Beginners Make Constantly
Errors You Will Hit — And Exactly Why They Happen
🎯 Key Takeaways
- ✓Three ingestion patterns cover every source: Full Load (read everything, replace destination), Incremental High-Watermark (read only changed rows since last checkpoint), and CDC (read the database transaction log for every operation). Every source table fits one of these three.
- ✓Full load is the right choice for small reference tables (under 1 million rows), tables with no reliable change tracking, and tables where deletes must be reflected and CDC is too complex. Use the staging table swap variant to avoid the empty-table window that truncate-and-reload creates.
- ✓Incremental ingestion scales to billions of rows because extraction time is proportional to change volume, not total table size. It requires a reliable high-watermark column (updated_at is ideal). It cannot detect hard deletes — deleted rows are invisible to any query-based extraction.
- ✓CDC reads the database transaction log (WAL in PostgreSQL) to capture every INSERT, UPDATE, and DELETE as a structured event. It is the only pattern that captures hard deletes with the before-image of the deleted row. It requires wal_level=logical on PostgreSQL and a replication slot.
- ✓Watermark columns: updated_at (best — works for updates), created_at (only for insert-only tables), auto-increment PK (only for insert-only tables with sequential inserts). When none is available: CDC or full load.
- ✓The four incremental ingestion pitfalls: hard deletes are invisible, missing updated_at forces full load or CDC, clock skew between source and pipeline server can skip rows (fix: use source DB's NOW() as upper bound), and late-arriving updates miss the window (fix: overlap the lower bound by 30 minutes and upsert).
- ✓CDC infrastructure requires: wal_level=logical in postgresql.conf (requires DB restart), a dedicated replication user with REPLICATION privilege, a replication slot, and a Debezium connector publishing to Kafka. Always use Schema Registry with Debezium.
- ✓Replication slot monitoring is critical. An unmonitored slot on a high-write database can fill the server disk and crash the production database. Alert when lag exceeds 10 GB or 30 minutes. If a slot is stale and unrecoverable, drop it rather than risk disk full.
- ✓CDC provides at-least-once delivery — the same event can be delivered more than once on consumer restart. The destination must handle this idempotently with upserts and UNIQUE constraints on the business key. Never use plain INSERT with CDC.
- ✓Most production platforms use all three patterns simultaneously: full load for reference tables (nightly, fast), incremental for large transaction tables (every 15 minutes), and CDC for financial and customer tables where deletes matter (continuous). Match the pattern to the table's characteristics, not to a personal preference.
What comes next
Module 24 goes deep on Change Data Capture — log-based, trigger-based, and query-based CDC from the inside, including production gotchas around replication lag, schema changes, and log retention.
Module 24 → Change Data Capture (CDC) — How It Works Under the HoodDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.