skip to content

In LanceDB, what does table.merge_insert() do that table.add() cannot?

level: middleimportance: should knowfreq 48%

answer

  1. append versus match-then-write
  2. no primary key is enforced
  3. three clauses: matched, not matched, not by source
  4. one commit instead of delete then add
  5. the point is idempotent ingestion

basics

~20 s

merge_insert() matches incoming rows against existing ones on a key column and applies update-or-insert semantics in a single commit. add() only appends, so re-running it with the same records silently creates duplicates — LanceDB tables have no primary-key constraint to stop it.

solid answer

~40 s

`table.add(data)` is a pure append: it writes the rows and commits, with no key checking, so an ingest job that reruns after a partial failure duplicates everything it already wrote. `merge_insert` builds an upsert instead: `table.merge_insert("id").when_matched_update_all().when_not_matched_insert_all().execute(new_data)` joins the incoming batch to the table on `id`, overwrites the matched rows, inserts the unmatched ones, and commits the whole thing as one version. Adding `when_not_matched_by_source_delete()` also removes rows absent from the incoming batch, which turns the call into a full replace of a slice — useful when re-embedding one tenant's documents. The point for interviews is idempotency: because LanceDB enforces no uniqueness, `merge_insert` on a stable key is what makes re-running an ingest safe, rather than deduplicating afterwards in application code.

go deeper

for a junior

Know that add() only appends and will happily create duplicate ids, and that merge_insert exists to do update-or-insert on a key column instead.

for a middle

Be able to write the builder chain — merge_insert on the key, when_matched_update_all, when_not_matched_insert_all, execute — and explain that it commits once, unlike delete-then-add.

for a senior

Show the ingestion design: a stable merge key, batched merges, an index on the key column, and the not-matched-by-source delete clause scoped by a where condition to replace one slice safely.

for a principal

Own idempotency as a pipeline property — where keys are minted, how retries and replays are made harmless, and the cost of fragment rewriting versus the alternative of deduplicating downstream at query time.

## The gap merge_insert fills LanceDB tables have an Arrow schema but no primary key and no uniqueness constraint. Nothing in the storage layer objects if you insert the same `id` twice. `table.add(data)` reflects that honestly: it appends rows, commits a version, and is done. Every real pipeline eventually hits the consequence — a re-embedding job re-runs, a Kafka consumer replays, a nightly load overlaps yesterday's — and the table now has two rows per document, both returned by search, both counted, neither obviously wrong. `merge_insert` is the operation that closes the gap: a keyed merge between the incoming batch (the *source*) and the table (the *target*), expressed as a small builder. ## The builder `table.merge_insert("id")` names the column (or columns) to match on and returns a builder. You then declare what happens in each of the three cases: - `when_matched_update_all()` — rows present in both: overwrite the target row with the source row. An optional `where` condition narrows which matches actually update, for example only when a version column is newer. - `when_not_matched_insert_all()` — rows only in the source: insert them. - `when_not_matched_by_source_delete()` — rows only in the target: delete them, optionally restricted by a `where` condition. `.execute(new_data)` runs it. Whichever clauses you omit simply do nothing for that case, so the same builder expresses several distinct operations. ## The three useful shapes **Upsert** — `when_matched_update_all()` + `when_not_matched_insert_all()`. The everyday shape: new documents land, changed documents are replaced, nothing duplicates. Re-running the same batch is a no-op in effect, which is exactly what makes a pipeline retry-safe. **Insert-if-absent** — `when_not_matched_insert_all()` alone. Backfills that must never clobber a newer value. **Replace a slice** — all three clauses, with the delete clause restricted by a `where` on, say, a tenant or source column. This makes the table's rows for that slice exactly equal to the incoming batch, which is how you re-embed one customer's corpus without touching anyone else's. ## Why not delete-then-add The hand-rolled alternative is `table.delete("id IN (...)")` followed by `table.add(new_rows)`. It works, but it is two commits with a window between them where the rows are missing — a concurrent reader can see the deletion but not the reinsertion. It also requires you to build the id list, which for large batches means an ugly filter string. `merge_insert` commits once, so readers move from the old state straight to the new one. ## What it costs A merge is not free. Matching requires scanning or looking up the key column across the table, and updating rows means rewriting the fragments that contain them, not patching bytes in place — so a merge that touches rows scattered across the whole dataset rewrites a lot of fragments. Two habits follow: put a scalar index on the key column so matching does not become a full scan on a large table, and batch your merges rather than calling `merge_insert` per row, which would commit a version and rewrite fragments each time. Also remember that, like every write, a merge commits a new version, and the pre-merge data survives in the previous version until pruning reclaims it. That is a feature when a bad merge needs rolling back, and a storage cost otherwise. ## Choosing a key The merge key must be stable across runs — a document id, a URI, a content hash. A key derived from row order or from the embedding itself is not stable, and the merge silently degenerates into inserts. If a natural key does not exist, minting one upstream in the pipeline is far cheaper than deduplicating a table afterwards. ## The interview answer Say: `add` appends, unconditionally; `merge_insert` matches on a key and applies update/insert/delete rules in one atomic commit; and the reason it matters is that LanceDB enforces no uniqueness, so idempotent ingestion is the writer's job and this is the tool for it.

  • What does adding when_not_matched_by_source_delete() change about the operation?
    It removes rows that exist in the table but not in the incoming batch, so after the merge the table's rows for that key space equal the source exactly. With a `where` condition on the clause you can scope the deletion to one tenant or source, which turns the call into a safe full replacement of a slice rather than a whole-table sync.
  • Why not just delete the matching ids and then add the new rows?
    Two reasons. It is two commits, so there is a window in which a concurrent reader sees the rows deleted but not yet reinserted, whereas `merge_insert` commits once and readers jump straight from old to new. It also forces you to construct the id list and a filter string yourself, which gets unwieldy and slow for large batches.
  • What makes a merge_insert slow on a large table, and what helps?
    Matching has to find the incoming keys in the table, and updating rewrites the fragments that hold the matched rows rather than patching them in place. A scalar index on the merge key keeps matching from degrading into a full scan, and batching — one merge per large batch instead of per row — avoids committing a version and rewriting fragments repeatedly.

saying these in an interview costs you the question

  • Assuming an id column is enforced as a primary key
  • Thinking add() will overwrite a row with a matching id
  • Calling merge_insert per row in a loop over a batch
  • Believing an update patches the row in place
  • Using an unstable key so every merge becomes an insert

context