Write Path Architecture#
Overview#
LanceDB's write path is implemented in Rust under rust/lancedb/src/table/ and exposes three main data ingestion operations: add, update, and merge_insert. Each path preprocesses data differently before delegating to lance-core. All paths ultimately produce a new dataset version committed to the lance storage layer.
add — Append or Overwrite#
Entry point: add_data.rs → AddDataBuilder
AddDataBuilder::into_plan builds a DataFusion execution plan that applies the following preprocessing steps in order :
- Schema validation — rejects columns not present in the table schema; missing table columns are allowed (filled with nulls). Skipped entirely in
Overwritemode . - Embedding computation — if the table has embedded columns,
scannable_with_embeddingsfills them . - Schema casting —
cast_to_table_schema(see Schema Casting) projects and casts input fields to match the table schema. Not applied inOverwritemode — the input schema replaces the table schema entirely . - NaN guard —
reject_nan_vectorswraps the plan to error on any NaN values in vector columns (configurable viaNaNVectorBehavior::Keep) .
The resulting plan is passed to InsertExec , which runs it across partitions, accumulates lance uncommitted transactions, and finally commits via CommitBuilder::execute() .
Key behavior differences between Append and Overwrite:
Append: schema validation + casting applied; input must be a sub-schema.Overwrite: both validation and casting are skipped; input schema replaces the table schema. Raw binary is not coerced into blob struct layout on overwrite .
update — SQL-Expression Column Updates#
Entry point: update.rs → execute_update
The update path is the simplest of the three. No DataFusion preprocessing pipeline is built. Instead :
- Snapshots the current dataset.
- Creates a
LanceUpdateBuilderdirectly from lance-core. - Configures
update_where()(WHERE clause) and one or moreset()expressions (SET clause). - Calls
builder.build()?.execute()— lance-core handles the rest. - Updates the cached dataset wrapper with the new version.
Columns to update are specified as (column_name, sql_expression) pairs ; the expression is evaluated against each row's current values. No embedding re-computation or schema coercion occurs on this path.
merge_insert — Upsert / Conditional Insert-or-Update#
Entry point: merge.rs → execute_merge_insert
merge_insert is the most complex path and has two sub-routes:
Standard Path#
When no LsmWriteSpec is installed (or when use_lsm(false) is set), the standard path :
- Creates
LanceMergeInsertBuilderand configures three behaviors:when_matched—UpdateAll,DoNothing, or conditional update.when_not_matched—InsertAllorDoNothing.when_not_matched_by_source—Delete, conditional delete, orKeep.
- Calls
builder.try_build()?.execute_reader(new_data)to delegate entirely to lance-core. - Returns
MergeResultwith insert/update/delete counts .
Input on this path goes directly to lance-core as a RecordBatchReader without the DataFusion preprocessing pipeline used by add. Indexes can be leveraged for the join key via use_index (default: true) .
LSM / MemWAL Path#
When a LsmWriteSpec is installed and the merge shape is an upsert (when_matched_update_all + when_not_matched_insert_all), lsm_dispatch_decision routes to execute_lsm_merge_insert :
- Collects all input batches and aligns them to the target schema.
- Validates that all rows route to a single shard.
- Writes to a
ShardWriter(MemWAL) viawriter.put(batches)— no commit is issued immediately.
In this mode, MergeResult only populates num_rows; insert/update breakdown is not known until compaction .
Schema Casting (cast_to_table_schema)#
Source: datafusion/cast.rs → cast_to_table_schema / build_field_exprs
Applied on add (Append mode only) via a DataFusion ProjectionExec. Per-field rules in build_field_exprs :
| Situation | Action |
|---|---|
| Schemas already equal | Return input plan unchanged |
arrow.json input → lance.json table field | Pass through unchanged; let lance-core handle JSONB conversion |
| Table field is blob v2 and input differs | Delegate to coerce_blob_expr (see below) |
| Struct with differing children | Recurse: extract sub-fields, cast, rebuild with named_struct |
| Types differ and are castable | Insert CastExpr |
| Types differ and are not castable | Return InvalidInput error |
Column matching is by name, not position, so schema evolution (adding columns after embedding columns) is handled correctly .
Blob V2 Descriptor Coercion#
Source: datafusion/blob_coerce.rs → coerce_blob_expr
Applied when the table field is marked as blob v2 and the input field differs. Three accepted input forms :
- Raw binary (
Binary,LargeBinary,BinaryView) — data is placed in thedatachild field; all other struct fields become typed nulls. - Pre-built struct with
dataorurichild present — fields are extracted viaget_field(), cast to declared types, and any missing declared fields are filled with typed nulls. - Null — written as a null descriptor row.
The result is a DataFusion named_struct physical expression producing a properly shaped blob v2 descriptor. Users supply raw bytes; they never need to construct the descriptor manually.
Key Source Files#
| File | Role |
|---|---|
rust/lancedb/src/table/add_data.rs | AddDataBuilder, into_plan, validate_schema, AddDataMode |
rust/lancedb/src/table/update.rs | UpdateBuilder, execute_update |
rust/lancedb/src/table/merge.rs | MergeInsertBuilder, execute_merge_insert |
rust/lancedb/src/table/merge/lsm.rs | LSM/MemWAL dispatch and execute_lsm_merge_insert |
rust/lancedb/src/table/datafusion/cast.rs | cast_to_table_schema, build_field_exprs |
rust/lancedb/src/table/datafusion/blob_coerce.rs | coerce_blob_expr — blob v2 descriptor coercion |
rust/lancedb/src/table/datafusion/insert.rs | InsertExec — lance-core transaction plumbing for add |