Skip to content

Incremental: Add the upsert and cdc strategies - #422

Merged
amotl merged 5 commits into
panodata:mainfrom
hampsterx:feat/incremental-cdc-upsert
Oct 22, 2026
Merged

amotl merged 5 commits into
panodata:mainfrom
hampsterx:feat/incremental-cdc-upsert

Conversation

@hampsterx

@hampsterx hampsterx commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Two new --incremental-strategy values for #396, both dlt merge strategies keyed on the primary key.

  • upsert inserts new rows and updates changed ones in place, and deletes nothing.
  • cdc keeps the destination equal to the source: it also deletes the rows the source no longer holds. Until now only replace could drop a vanished row, by rewriting the whole table.
  • Both unflatten onto a merge write disposition that names the strategy, the path scd2 already took. The runs_scd2 special case becomes one named-merge-strategy path shared by all three.
  • cdc deletes whatever a run does not read, so it shares scd2's refusal of partial reads: --incremental-key, --sql-limit, --yield-limit, and --interval-start / --interval-end on a query: table that binds them into its placeholders.
  • cdc is accepted for SQL database sources only (SQL_SOURCE_SCHEMES). Other sources can keep a cursor or a default window of their own (Google Ads' rolling segments_date, Kafka offsets, the saved-query windows in Allium and Dune), and on dlt 1.31 that is not visible before extraction: DltResource.incremental is a wrapper on every resource whose signature takes an incremental, and the inner Incremental only exists once the generator runs. The refusal sits ahead of the handles_incrementality() suppression, so a source that owns its disposition gets an error rather than a silently dropped cdc, on --dry-run too.
  • Both need a primary key. dlt refuses a keyless table only at the load step, as a traceback, so the resource's computed schema is checked first; a table with a declared key is reflected and needs no flag.
  • Both are checked against the merge strategies the destination supports for the table (capabilities through its selector, with the table format its run parameters set), before --dry-run returns. dlt refuses a missing strategy only at the load step, and where a destination has no merge strategy for the table it refuses nothing: bucket destinations fall back to append and MongoDB's sink upserts, so cdc would report success having deleted nothing.
  • The dlt floor moves to >=1.31, the first release with the cdc strategy.

Docs: updated

Behaviour changes

  • A plain merge or delete+insert now names its merge strategy. dlt keeps a table's merge strategy in the stored schema when a run names none, so after a cdc load a plain merge with --sql-limit still ran cdc and deleted the rows it did not read. The bare disposition now names the destination's first supported strategy, which is the one dlt picks by default. That replaces any stored strategy, scd2 included: a table loaded with scd2 and then with a plain merge keeps no history for the keys that load reads. Destinations that declare no merge strategies keep the bare merge.
  • scd2: --interval-start / --interval-end on a query: table that uses the placeholders now fail the partial-read check. On a plain table they only take effect through --incremental-key, so they are not refused there.

Limits

  • Empty source table under cdc depends on the backend. With sqlalchemy and pyarrow (the default) a read that yields no rows produces no load job, so nothing is deleted. With connectorx an empty Arrow table reaches the merge and the destination is emptied, which matches the source. Both are pinned by tests (the connectorx one skips where connectorx is not installed, which includes CI). adbcbridge is not verified.
  • A table first loaded with delete+insert keeps its incremental key as a stored merge key, so a later cdc run deletes only inside the key values it loaded. Documented with --full-refresh as the remedy and pinned by a test.
  • The primary-key check runs after --dry-run returns, because it needs the built resource. A keyless dry run passes; the real run is refused. A table with no declared key needs --primary-key on every run, even where dlt could take it from the stored schema.
  • CrateDB reports Postgres' strategies and fails at SQL execution, as it already does for merge; the destination check cannot see that. The CrateDB page lists both strategies as unsupported.
  • A source that sets a bare resource-level merge itself is outside the strategy naming: a table it shares with an earlier cdc load would still inherit cdc.

Out of scope

  • The merge-condition knobs (source_filter, destination_scope), skip_unchanged_rows, and a --merge-key for partition-scoped cdc deletes.
  • Sources with their own cursor under scd2, and the destination check for scd2. Both rules apply to cdc (and the destination check to upsert) only, so existing scd2 runs are unchanged.

Review

  • Two independent reviews from different model families read the plan, then each commit range until both approved. Applied: the strategy naming on a plain merge (a stored cdc strategy deleted unread rows), reading capabilities from the resolved destination (MongoDB's factory destination raised), the query: wording of the primary-key error.
  • A fresh-context analysis pass read the whole branch, broke each new guard in a scratch copy to confirm its test fails, and was re-run after its fixes. Applied: the destination check above, Delta Lake in the supported list, upsert and cdc in the csv, Delta Lake and filesystem-incremental rejection lists, Turso's refusal tests.
  • Deferred from the analysis pass:
    • docs/getting-started/incremental-loading.md:297: the SCD2 section could repeat the changelog's note that a later plain merge drops history for the keys it reads (declined: stated in the changelog).
    • src/omniload/api.py:999: the destination refusal lists dlt's strategy names (delete-insert) rather than --incremental-strategy values (mention).
    • src/omniload/api.py:982: the destination check does not cover scd2, which still appends into a bucket or upserts into MongoDB (mention; pre-existing).
    • docs/supported-sources/csv.md:85: upsert's rejection reason repeats the error text, which names an incremental or merge key though upsert keys on the primary key (mention).
    • tests/main/test_incremental_strategy_cdc.py:432: the connectorx empty-source pin needs connectorx installed to run (track).

Changes

  • src/omniload/model.py: upsert and cdc members.
  • src/omniload/api.py: the named-merge-strategy path, the SQL-only cdc refusal, the shared partial-read check with the interval options, the early primary-key check, the destination strategy check, and naming the default strategy on a plain merge.
  • pyproject.toml: dlt[deltalake]>=1.31,<1.32.
  • tests/main/test_incremental_strategy_cdc.py (new), tests/main/filesystem/test_incremental_strategy.py, tests/main/test_turso.py.
  • docs/getting-started/incremental-loading.md: Upsert and CDC sections; docs/commands/ingest.md, the ClickHouse, CrateDB, CSV, Delta Lake and Turso pages, the changelog, and upsert in the Vale vocabulary.

Test plan

  • tests/main/test_incremental_strategy_cdc.py: 47 passed, 1 skipped (connectorx absent). Delete and update round trips for both strategies; the partial-read refusal for scd2 and cdc over every option; the non-SQL refusal (own-cursor stub, managed source, mmap://, with and without --dry-run); primary-key refusal, reflection, the query: wording and the dynamic-hints deferral; both Arrow backends; the empty-source and merge-key pins; the InfluxDB --primary-key override with upsert; a limited merge / delete+insert after cdc keeping the unread rows; a merge dry run into MongoDB's factory destination; the destination refusal (SQLAlchemy, MongoDB, a bucket) and Delta Lake accepting both.
  • tests/main/test_turso.py: upsert and cdc join the merge refusals, on a load and on a dry run.
  • tests/main/filesystem/test_incremental_strategy.py: upsert joins the key-strategy rejection, and cdc gets the non-SQL refusal.
  • Negative controls, each in a scratch copy: disabling the SQL-only check fails its seven tests; disabling the interval refusal fails its parametrizations; dropping the named default strategy fails the merge-after-cdc case (the delete+insert case passes either way, since its own merge key scopes the stored cdc delete, and stays as a regression assertion); reading capabilities from the unresolved destination fails the MongoDB test; dropping the destination check fails the SQLAlchemy, MongoDB and bucket cases; skipping the selector fails the bucket cases; dropping the table format fails the Delta cases.
  • Every existing scd2 test passes unchanged.
  • poe lint clean; sphinx-build -W clean; Docker-free unit lane 1,390 passed, 45 skipped.
  • Full suite on the first commit: 1,974 passed, 1 failed (tests/warehouse/db/test_mongodb.py::test_mongodb_source[postgres], the MongoDB container closed the connection under parallel load); that file passes 20/20 on its own.
  • CI: unit (Linux, macOS, Windows on 3.10 and 3.14), integration (3.10, 3.14), docs build, all green on b606171a.

`--incremental-strategy upsert` inserts and updates by primary key and
deletes nothing. `--incremental-strategy cdc` keeps the destination equal
to the source, deleting the rows the source no longer holds, which until
now only `replace` could do, by rewriting the whole table.

Both are dlt merge strategies, so they unflatten onto a `merge` write
disposition that names them, the same path `scd2` takes. The `scd2`
special case becomes one named-merge-strategy path.

cdc deletes whatever a run does not read, so it shares scd2's refusal of
partial reads, and is accepted for SQL database sources only: other
sources can keep a cursor or a default window of their own that cannot
be seen before the read. A source that owns its disposition has cdc
refused rather than silently dropped. Both strategies need a primary key,
checked before the load so a keyless table gets a one-line error instead
of dlt's traceback at the load step.

The partial-read check also covers `--interval-start` / `--interval-end`
on a `query:` table that binds them into its placeholders, which windows
the read without an incremental key. That applies to scd2 too.

dlt 1.31 is the minimum version, the first with the cdc strategy.
… strategy does not linger

dlt keeps a table's merge strategy in its stored schema when a run names
none, so after a cdc load a plain merge or delete+insert with a limited
read still ran cdc and deleted every row it did not read. A bare merge
disposition now names the destination's first supported strategy, which
is the one dlt would default to.

Also: name a query: table as the query in the primary-key error, test
the deferral for dynamic hints, and say that sources managing their own
incrementality ignore upsert.
A destination may hand dlt a factory callable, as MongoDB does, which has
no capabilities of its own, so naming the default merge strategy on a
plain merge raised AttributeError there, on --dry-run too. Read them from
the pipeline's destination instead, which dlt has resolved.

The changelog now says the named strategy replaces any strategy an
earlier load stored, not only cdc.
…rategy

dlt checks a named merge strategy against the destination only at the
load step, and does not refuse one where the destination has no merge
strategy for the table at all: it appends instead (bucket destinations),
or a custom sink merges its own way (MongoDB upserts), so cdc reported
success and deleted nothing. upsert and cdc are now checked against the
strategies the destination supports for this table, through its selector
and the table format its run parameters set, before --dry-run returns.

Docs: Delta Lake supports both; the unsupported list names MongoDB and
the bucket destinations; csv, deltalake and the filesystem-incremental
sentence list upsert and cdc as rejected; the primary-key line says
--dry-run does not check it. Tests: Turso refuses upsert and cdc, and
the partial-read cases restore dlt's table_rows so a regression cannot
spread to unrelated tests.
A bucket destination lists merge strategies but has none for a table
without a table format, which only the destination's selector reveals.
@coderabbitai

coderabbitai Bot commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one): 1
  • coderabbit-review

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@read-the-docs-community

Copy link
Copy Markdown

@codecov

codecov Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 60.24%. Comparing base (9f1d2fc) to head (b606171).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #422      +/-   ##
==========================================
+ Coverage   60.11%   60.24%   +0.13%     
==========================================
  Files         201      201              
  Lines       10311    10344      +33     
==========================================
+ Hits         6198     6232      +34     
+ Misses       4113     4112       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@amotl amotl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent!

@amotl
amotl merged commit 41aa2f5 into panodata:main Oct 22, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants