
Imagine you've built a pipeline that syncs your e-commerce orders table from Postgres into Snowflake every hour. It works beautifully — right up until the day your orders table hits 50 million rows. Suddenly, each sync takes 40 minutes, your warehouse credits are burning through the roof, and your data team is getting angry Slack messages about costs. The root cause? Your connector is still doing a full table scan every single hour, re-copying all 50 million rows just to capture the 2,000 new orders that came in since the last run.
This is one of the most common and most expensive mistakes in data engineering, and it's entirely preventable. Both Fivetran and Airbyte — two of the most widely used EL (Extract-Load) tools in the modern data stack — offer incremental sync modes that copy only the data that has changed since the last run. But configuring them correctly requires understanding a handful of concepts: sync modes, cursor fields, and Change Data Capture (CDC). Get these right, and you can cut your warehouse load by 90% or more on large, active tables.
By the end of this lesson, you'll be able to look at any table in your data stack and confidently decide how it should be synced — and then configure that sync correctly in Fivetran or Airbyte.
What you'll learn:
Before we talk about incremental sync, let's make sure we really understand what a full refresh does, because it's intuitive and that's exactly what makes it dangerous.
In a full refresh sync, your EL tool connects to the source, runs something equivalent to SELECT * FROM orders, takes every single row, and loads all of it into the destination. If rows already exist in the destination, they're either replaced or the table is truncated and rebuilt from scratch. Every. Single. Sync.
This is fine when your tables are small. A 10,000-row table full-refreshes in seconds and costs almost nothing. But data has a way of growing. A busy SaaS app might generate 100,000 new event records per day. After a year, that's 36 million rows. After two years, 73 million rows. If you're syncing that table every hour, you're doing 8,760 full scans of an ever-growing table per year. Your warehouse query costs scale linearly with data volume, and full refresh makes you pay for the entire history every single time.
The alternative is incremental sync: instead of asking "give me everything," you ask "give me everything that changed since the last time I asked." This is the core idea, and everything else in this lesson is about how to implement it correctly.
There are fundamentally two ways a sync tool can figure out what's changed since the last run. Understanding the difference between them is the most important conceptual step in this lesson.
A cursor field is a column in your source table that monotonically increases over time — meaning it never goes backward. The most common examples are:
updated_at — a timestamp that gets set whenever a row is created or modifiedcreated_at — a timestamp set only at row creationid — an auto-incrementing integer primary keyHere's how cursor-based sync works: after each successful sync, the tool saves the maximum value it saw in the cursor field. On the next run, it only queries for rows where the cursor field is greater than that saved value. In SQL terms, it's essentially doing this:
SELECT *
FROM orders
WHERE updated_at > '2024-03-15 14:00:00'
The saved value (2024-03-15 14:00:00 in this example) is called the watermark or cursor state. It moves forward with each successful sync like a bookmark.
This approach is elegant and simple, but it has one critical limitation: it only works if your source table actually updates the cursor field when a row changes. If a developer inserts a row with updated_at hardcoded to an old date, or if your source system has a bug where updated_at doesn't get touched on updates, those rows will be silently skipped. More on this in the troubleshooting section.
Change Data Capture, or CDC, is a fundamentally different approach. Instead of querying the source table directly, CDC reads from the database's internal transaction log — the record the database itself keeps of every insert, update, and delete operation.
Most relational databases maintain this log for their own internal purposes (replication, crash recovery). It goes by different names depending on the database: the WAL (Write-Ahead Log) in Postgres, the binlog in MySQL, the redo log in Oracle. CDC connectors tap into this log and stream changes in real time.
The power of CDC is completeness and accuracy. Because you're reading from the transaction log rather than inferring changes from a column value, you capture:
The trade-off is complexity. CDC requires specific database configuration (enabling logical replication in Postgres, for example), appropriate permissions, and more operational overhead. It's also more sensitive to source database load and log retention settings.
Not every table in your source system needs the same treatment. Here's a practical decision framework:
Use full refresh when:
countries lookup tableUse cursor-based incremental when:
updated_at timestamp that gets updated on every writeUse CDC when:
Fivetran takes an opinionated approach to sync configuration — it does a lot of the heavy lifting for you, which is both a strength and a constraint. Let's walk through how it works in practice.
For most database connectors (Postgres, MySQL, SQL Server, etc.), Fivetran defaults to using CDC via the database's replication log when possible. When CDC isn't available or isn't configured, it falls back to a cursor-based approach using the primary key and a _fivetran_synced metadata column it manages itself.
For API-based connectors (Salesforce, Stripe, HubSpot), Fivetran manages the cursor internally — you don't choose the cursor field, because Fivetran knows the API's pagination and filtering endpoints better than you do.
To configure a new Postgres connector in Fivetran using CDC (logical replication):
-- Enable logical replication (requires superuser and postgresql.conf change)
-- In postgresql.conf: wal_level = logical
-- Create a replication slot for Fivetran
SELECT pg_create_logical_replication_slot(
'fivetran_slot',
'pgoutput'
);
-- Create a publication for the tables you want to replicate
CREATE PUBLICATION fivetran_pub FOR TABLE orders, customers, products;
fivetran_slot as the replication slot name and fivetran_pub as the publication name.Warning: Postgres logical replication slots hold onto WAL segments until they're consumed. If your Fivetran sync is paused for an extended period, your Postgres server's disk can fill up with unread WAL data. Monitor replication slot lag closely and set
max_slot_wal_keep_sizeif you're on Postgres 13+.
For sources that don't support CDC, Fivetran uses what it calls Standard sync mode, which is cursor-based. You can view (but not always change) the sync behavior per-table in the connector's schema settings. To get there:
Airbyte gives you significantly more control — and therefore more responsibility — than Fivetran. You explicitly choose the sync mode and cursor field for each stream (Airbyte's term for a table or endpoint).
Airbyte defines sync modes as a combination of source behavior and destination behavior:
| Source Mode | Destination Mode | What it does |
|---|---|---|
| Full Refresh | Overwrite | Full refresh, destination table is replaced |
| Full Refresh | Append | Full refresh, new data is appended (history preserved) |
| Incremental | Append | Only new/changed rows, appended to destination |
| Incremental | Append + Dedup | Only new/changed rows, destination deduplicates by primary key |
| CDC | Append + Dedup | CDC source, destination deduplicates |
For production incremental pipelines, Incremental | Append + Dedup is almost always what you want. It ensures that if a row is updated at the source, you don't end up with duplicate rows in your warehouse — the dedup step keeps only the latest version.
When you configure a connection in Airbyte:
orders table in our example, set:updated_atorder_idThe cursor field tells Airbyte what to compare against its saved watermark. The primary key tells the dedup logic how to identify "the same" row across multiple syncs.
Tip: If your table has both
created_atandupdated_at, always chooseupdated_atas your cursor field. If you usecreated_at, updates to existing rows will never be captured — you'll only ever see inserts.
Airbyte's Postgres CDC source connector uses the same Postgres logical replication mechanism as Fivetran, but you configure it differently:
pg_create_logical_replication_slot and CREATE PUBLICATION).With CDC active in Airbyte, each stream in the connection automatically gets CDC-based incremental behavior. You'll still want to set the destination mode to Append + Dedup so that updates and deletes are handled correctly at the destination.
Choosing the right cursor field is where incremental sync either succeeds or quietly fails. Here are the properties you need:
A good cursor field:
WHERE updated_at > ? query is fast)A bad cursor field:
created_at on a table where records are frequently updatedA classic trap is using id as a cursor field on an orders table where order statuses get updated. New orders get new IDs (so they're captured), but when an order status changes from "pending" to "shipped," the id doesn't change — so that update is completely invisible to the incremental sync. Your warehouse ends up with stale order statuses and no error to tell you something's wrong.
Warning: Broken cursor fields cause silent data quality issues — your pipeline appears healthy but your data is wrong. Always validate incremental sync by occasionally running a row count and a checksum comparison between source and destination.
This exercise walks you through configuring an Airbyte incremental sync for a realistic scenario and verifying it's working correctly.
Scenario: You have a customer_events table in Postgres that records user activity in a SaaS app. It has columns: event_id (integer, primary key), customer_id, event_type, event_data (jsonb), occurred_at (timestamp), and updated_at (timestamp). The table currently has 8 million rows and grows by ~50,000 rows per day. You want to sync it to BigQuery incrementally.
Step 1: Inspect the source table
Connect to your Postgres source and run:
-- Check for NULLs in your intended cursor field
SELECT COUNT(*) FROM customer_events WHERE updated_at IS NULL;
-- Verify the index exists
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'customer_events'
AND indexdef LIKE '%updated_at%';
-- Check recent activity range
SELECT MIN(updated_at), MAX(updated_at)
FROM customer_events;
If the NULL check returns any rows, you have a problem to fix before configuring incremental sync.
Step 2: Configure the Airbyte connection
customer_events.updated_at, primary key to event_id.Step 3: Run the initial sync and record metrics
Trigger a manual sync and note:
Step 4: Insert a test row and verify incremental behavior
-- Insert a new event
INSERT INTO customer_events (customer_id, event_type, event_data, occurred_at, updated_at)
VALUES (42, 'page_view', '{"page": "/pricing"}', NOW(), NOW());
-- Update an existing event (simulating a correction)
UPDATE customer_events
SET event_data = '{"page": "/pricing", "corrected": true}',
updated_at = NOW()
WHERE event_id = 1000;
Trigger another sync. The job logs should show exactly 2 rows synced — the new insert and the updated row. Check BigQuery to confirm the update is reflected in the destination.
Step 5: Compare full refresh vs. incremental timing
Temporarily switch the sync mode to Full Refresh, run one sync, and note the duration and row count. Switch back to Incremental. The difference in duration is your incremental sync dividend — the time and cost you're saving on every subsequent run.
Mistake 1: The watermark never advances
Symptom: Each sync copies the same rows over and over. Rows from the first sync keep appearing in the destination.
Cause: The cursor field is a constant value (e.g., updated_at is set once at creation and never updated).
Fix: Switch to using created_at if you only care about inserts, or implement a database trigger/application code change to update updated_at on every write. For updates, CDC is the proper long-term solution.
Mistake 2: Hard deletes disappear
Symptom: A customer deleted their account at the source, but their record still shows up in your warehouse.
Cause: Cursor-based incremental sync has no mechanism to detect deleted rows. It only sees what's there, not what's gone.
Fix: Either use CDC (which reads DELETEs from the transaction log), implement soft deletes at the source (add a deleted_at timestamp column instead of actually removing rows), or run periodic reconciliation queries.
Mistake 3: Clock skew causes missed rows
Symptom: Sporadic missing rows that appear hours or days later.
Cause: If your source database's clock is slightly ahead of the system recording the watermark, rows written right at the boundary of a sync window can be missed.
Fix: In Airbyte, you can configure a lookback window that re-reads a small overlap period to catch boundary rows. In Fivetran, CDC mode eliminates this problem entirely since it reads from the log rather than polling by timestamp.
Mistake 4: Replication slot lag fills disk
Symptom: Postgres server disk usage climbs steadily; alerts fire about disk space.
Cause: A CDC replication slot holds WAL segments until they're consumed. If your sync is paused, delayed, or broken, the slot accumulates data.
Fix: Monitor replication slot lag with:
SELECT slot_name, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS lag
FROM pg_replication_slots;
Set up alerts if lag exceeds a threshold. Consider dropping and recreating the slot if the sync is going to be paused for more than a few hours.
You now have a complete mental model for how incremental sync works in modern EL tools, and you can make informed decisions about how to configure it.
Here's the key decision logic to take away: start with cursor-based incremental for any table over ~100,000 rows that has a reliable updated_at column. Validate that the cursor field is actually being updated by your application. Graduate to CDC when you need hard delete detection, real-time freshness, or when even cursor-based queries are too expensive at source.
In Fivetran, CDC is configured at the connector level through database replication slots and publications — Fivetran manages the cursor state for you. In Airbyte, you have explicit per-stream control over sync mode, cursor field, and primary key, giving you more flexibility at the cost of more configuration responsibility.
Next steps to deepen your skills:
MERGE statements work in your target warehouse helps you reason about the Append + Dedup sync mode's performance.Learning Path: Modern Data Stack