Repository: delta-io/delta-rs (2026-04-03T15:49:30Z)

Issue:
MERGE should raise when multiple source rows match target row
# Environment

**Delta-rs version**: 0.16.4

**Binding**: Python 0.16.4

**Environment**:
- **Cloud provider**: None
- **OS**: MacOS Sonoma 14.4
- **Other**:

***
# Bug

**What happened**:
TableMerger.when_matched_update_all() inserts records when there is a match on multiple source columns
TableMerger.when_matched_update() inserts records when there is a match on multiple source columns


**What you expected to happen**:
TableMerger.when_matched_update_all() should throw an error if a target record matches multiple source records 
TableMerger.when_matched_update() should throw an error if a target record matches multiple source records 

They should never insert new records.

**How to reproduce it**:
Using polars 0.20.7
```python
import polars as pl
from deltalake import DeltaTable

base_df = pl.DataFrame(
    {
        "id": [1, 2],
        "attr": ["x", "y"]
    }
)

base_df.write_delta("./test_cdc", mode="overwrite")

dt = DeltaTable("./test_cdc")

print(pl.DataFrame(dt.to_pyarrow_table()))

cdc_df = pl.DataFrame(
    {
        "id": [1,1,1,2,2],
        "attr": ["a","b","c","d","e"],
        "op": ["U", "U", "U", "U", "U"]
    }
)


(
    dt.merge(
        cdc_df.to_arrow(),
        "s.id = t.id",
        source_alias="s",
        target_alias="t",
    )
    .when_matched_update(
        updates={"t.attr": "s.attr"},
        predicate="s.op = 'U'")
    .execute()
)

print(pl.DataFrame(dt.to_pyarrow_table()))
```

Gives the following output

**base table**
```bash
shape: (2, 2)
┌─────┬──────┐
│ id  ┆ attr │
│ --- ┆ ---  │
│ i64 ┆ str  │
╞═════╪══════╡
│ 1   ┆ x    │
│ 2   ┆ y    │
└─────┴──────┘
```

**After TableMerger is executed**
```bash
shape: (5, 2)
┌─────┬──────┐
│ id  ┆ attr │
│ --- ┆ ---  │
│ i64 ┆ str  │
╞═════╪══════╡
│ 2   ┆ d    │
│ 2   ┆ e    │
│ 1   ┆ a    │
│ 1   ┆ b    │
│ 1   ┆ c    │
└─────┴──────┘
```
**More details**:
Info on the specific case in slack: https://delta-users.slack.com/archives/C013LCAEB98/p1712673309723829


Discussion:
@mrjsj were you still working on this? I remember you had some fixes ready
Hi @ion-elgreco 
I did try, but got stuck for some reason. My "progress" is here: https://github.com/delta-io/delta-rs/compare/main...mrjsj:delta-rs:fix/raise-if-source-has-duplicates

I'm not really confident in Rust
@ion-elgreco  i have worked a little more on this; in a new branch. Rust tests run fine, however Python unit tests are failing when `streaming_exec=True`.

the `execute()` never merges to the table. It happens when I `.collect().await?` on a DataFrame used for duplication detection (see link).

Any ideas how to solve this?

https://github.com/mrjsj/delta-rs/blob/23eebf9adb9c21967c93c92796f78d7c1d2fe906/crates/core/src/operations/merge/mod.rs#L928C1-L944C17
> @ion-elgreco  i have worked a little more on this; in a new branch. Rust tests run fine, however Python unit tests are failing when `streaming_exec=True`.
> 
> the `execute()` never merges to the table. It happens when I `.collect().await?` on a DataFrame used for duplication detection (see link).
> 
> Any ideas how to solve this?
> 
> https://github.com/mrjsj/delta-rs/blob/23eebf9adb9c21967c93c92796f78d7c1d2fe906/crates/core/src/operations/merge/mod.rs#L928C1-L944C17

That's because in streaming a RecordBatchStream can only be collected once, so first you collect it do your matchings checks, but when you write the table, there are no more incoming recordbatches.

You might need to look into extension nodes that can keep a state
Hi,

I saw the previous investigation and partial fixes by @mrjsj — really appreciate the effort there.

If this issue is still open, I’d be happy to take a look and see whether I can help, possibly in collaboration or by exploring an alternative approach.

Please let me know if that would be appropriate. I want to make sure I’m not duplicating or interfering with existing work.

Thanks!
Hi @kiwwwwwi 
I'm not currently working on this, so feel free to take a jab.
take
@kiwwwwwi let me know if you’d like any help with this. I’m more than happy to take a look if useful.
@ion-elgreco would it be OK if I took this one?
@adampolomski Yes go ahead! If you want to do an issue, you can say `take`in the comment
take



Patch:
diff --git a/crates/core/src/operations/merge/mod.rs b/crates/core/src/operations/merge/mod.rs
index 42af43bb2..56071d247 100644
--- a/crates/core/src/operations/merge/mod.rs
+++ b/crates/core/src/operations/merge/mod.rs
@@ -43,6 +43,7 @@ use datafusion::common::{
 use datafusion::datasource::provider_as_source;
 use datafusion::error::Result as DataFusionResult;
 use datafusion::execution::session_state::SessionStateBuilder;
+use datafusion::functions_window::expr_fn::row_number;
 use datafusion::logical_expr::build_join_schema;
 use datafusion::logical_expr::execution_props::ExecutionProps;
 use datafusion::logical_expr::simplify::SimplifyContext;
@@ -72,6 +73,7 @@ use tracing::*;
 use uuid::Uuid;
 
 use self::barrier::{MergeBarrier, MergeBarrierExec};
+use self::validation::{MergeValidation, MergeValidationExec};
 use super::{CustomExecuteHandler, Operation};
 use crate::delta_datafusion::expr::fmt_expr_to_sql;
 use crate::delta_datafusion::logical::MetricObserver;
@@ -102,17 +104,22 @@ use crate::{DeltaResult, DeltaTable, DeltaTableError};
 
 mod barrier;
 mod filter;
+mod validation;
 
 const SOURCE_COLUMN: &str = "__delta_rs_source";
 const TARGET_COLUMN: &str = "__delta_rs_target";
 
 const OPERATION_COLUMN: &str = "__delta_rs_operation";
 const DELETE_COLUMN: &str = "__delta_rs_delete";
+const TARGET_ROW_INDEX_COLUMN: &str = "__delta_rs_target_row_index";
 pub(crate) const TARGET_INSERT_COLUMN: &str = "__delta_rs_target_insert";
 pub(crate) const TARGET_UPDATE_COLUMN: &str = "__delta_rs_target_update";
 pub(crate) const TARGET_DELETE_COLUMN: &str = "__delta_rs_target_delete";
 pub(crate) const TARGET_COPY_COLUMN: &str = "__delta_rs_target_copy";
 
+// Duplicate match validation markers
+const TARGET_MATCH_CARDINALITY_CLASS_COLUMN: &str = "__delta_rs_match_cardinality_class";
+
 const SOURCE_COUNT_METRIC: &str = "num_source_rows";
 const TARGET_COUNT_METRIC: &str = "num_target_rows";
 const TARGET_COPY_METRIC: &str = "num_copied_rows";
@@ -536,6 +543,27 @@ enum OperationType {
     Copy,
 }
 
+/// Duplicate-match validation class encoding.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[repr(i32)]
+enum CardinalityClass {
+    Ignore = 0,
+    MatchedUnconditionalDelete = 1,
+    DuplicateInvalidating = 2,
+}
+
+impl CardinalityClass {
+    fn for_matched_operation(op_type: OperationType, is_unconditional: bool) -> Self {
+        match op_type {
+            OperationType::Delete if is_unconditional => Self::MatchedUnconditionalDelete,
+            OperationType::Delete | OperationType::Update | OperationType::Copy => {
+                Self::DuplicateInvalidating
+            }
+            OperationType::Insert | OperationType::SourceDelete => Self::Ignore,
+        }
+    }
+}
+
 //Encapsute the User's Merge configuration for later processing
 struct MergeOperationConfig {
     /// Which records to update
@@ -551,6 +579,8 @@ struct MergeOperation {
     /// How to update columns in a record that match the predicate
     operations: HashMap<Column, Expr>,
     r#type: OperationType,
+    /// Duplicate-match validation class for this operation.
+    cardinality_class: CardinalityClass,
 }
 
 impl MergeOperation {
@@ -610,8 +640,17 @@ impl MergeOperation {
             predicate: maybe_into_expr(config.predicate, schema, state)?,
             operations: ops,
             r#type: config.r#type,
+            cardinality_class: CardinalityClass::Ignore,
         })
     }
+
+    fn into_matched(mut self) -> Self {
+        let is_unconditional =
+            matches!(self.r#type, OperationType::Delete) && self.predicate.is_none();
+        self.cardinality_class =
+            CardinalityClass::for_matched_operation(self.r#type, is_unconditional);
+        self
+    }
 }
 
 impl MergeOperationConfig {
@@ -744,6 +783,18 @@ impl ExtensionPlanner for MergeMetricExtensionPlanner {
             }
         }
 
+        if let Some(validation) = node.as_any().downcast_ref::<MergeValidation>() {
+            if physical_inputs.len() != 1 {
+                return plan_err!("MergeValidationExec expects exactly one input");
+            }
+
+            let schema = validation.input.schema();
+            return Ok(Some(Arc::new(MergeValidationExec::new(
+                physical_inputs.first().unwrap().clone(),
+                planner.create_physical_expr(&validation.expr, schema, session_state)?,
+            ))));
+        }
+
         if let Some(barrier) = node.as_any().downcast_ref::<MergeBarrier>() {
             if physical_inputs.len() != 1 {
                 return plan_err!("MergeBarrierExec expects exactly one input");
@@ -946,6 +997,7 @@ async fn execute(
         }),
     });
     let target = DataFrame::new(state.clone(), target);
+    let target = target.with_column(TARGET_ROW_INDEX_COLUMN, row_number())?;
     let target = target.with_column(TARGET_COLUMN, lit(true))?;
 
     let join = source.join(target, JoinType::Full, &[], &[], Some(predicate.clone()))?;
@@ -953,7 +1005,10 @@ async fn execute(
 
     let match_operations: Vec<MergeOperation> = match_operations
         .into_iter()
-        .map(|op| MergeOperation::try_from(op, &join_schema_df, &state, &target_alias))
+        .map(|op| {
+            MergeOperation::try_from(op, &join_schema_df, &state, &target_alias)
+                .map(MergeOperation::into_matched)
+        })
         .collect::<Result<Vec<MergeOperation>, DeltaTableError>>()?;
 
     let not_match_target_operations: Vec<MergeOperation> = not_match_target_operations
@@ -1033,11 +1088,12 @@ async fn execute(
 
     let mut when_expr = Vec::with_capacity(operations_size);
     let mut then_expr = Vec::with_capacity(operations_size);
-    let mut ops = Vec::with_capacity(operations_size);
+    let mut ops: Vec<(HashMap<Column, Expr>, OperationType, CardinalityClass)> =
+        Vec::with_capacity(operations_size);
 
     fn update_case(
         operations: Vec<MergeOperation>,
-        ops: &mut Vec<(HashMap<Column, Expr>, OperationType)>,
+        ops: &mut Vec<(HashMap<Column, Expr>, OperationType, CardinalityClass)>,
         when_expr: &mut Vec<Expr>,
         then_expr: &mut Vec<Expr>,
         base_expr: &Expr,
@@ -1053,7 +1109,7 @@ async fn execute(
             when_expr.push(predicate);
             then_expr.push(lit(ops.len() as i32));
 
-            ops.push((op.operations, op.r#type));
+            ops.push((op.operations, op.r#type, op.cardinality_class));
 
             let action_type = match op.r#type {
                 OperationType::Update => "update",
@@ -1107,15 +1163,27 @@ async fn execute(
 
     when_expr.push(matched);
     then_expr.push(lit(ops.len() as i32));
-    ops.push((HashMap::new(), OperationType::Copy));
+    ops.push((
+        HashMap::new(),
+        OperationType::Copy,
+        CardinalityClass::DuplicateInvalidating,
+    ));
 
     when_expr.push(not_matched_target);
     then_expr.push(lit(ops.len() as i32));
-    ops.push((HashMap::new(), OperationType::SourceDelete));
+    ops.push((
+        HashMap::new(),
+        OperationType::SourceDelete,
+        CardinalityClass::Ignore,
+    ));
 
     when_expr.push(not_matched_source);
     then_expr.push(lit(ops.len() as i32));
-    ops.push((HashMap::new(), OperationType::Copy));
+    ops.push((
+        HashMap::new(),
+        OperationType::Copy,
+        CardinalityClass::Ignore,
+    ));
 
     let case = CaseBuilder::new(None, when_expr, then_expr, None).end()?;
 
@@ -1171,8 +1239,8 @@ async fn execute(
             Column::new(source_qualifier.clone(), name)
         };
 
-        for (idx, (operations, _)) in ops.iter().enumerate() {
-            let op = operations
+        for (idx, (operations, _, _)) in ops.iter().enumerate() {
+            let op: Expr = operations
                 .get(&column)
                 .map(|expr| expr.to_owned())
                 .unwrap_or_else(|| col(column.clone()));
@@ -1230,7 +1298,7 @@ async fn execute(
     let mut copy_when = Vec::with_capacity(ops.len());
     let mut copy_then = Vec::with_capacity(ops.len());
 
-    for (idx, (_operations, r#type)) in ops.iter().enumerate() {
+    for (idx, (_operations, r#type, _)) in ops.iter().enumerate() {
         let op = idx as i32;
 
         // Used to indicate the record should be dropped prior to write
@@ -1323,6 +1391,37 @@ async fn execute(
         LogicalPlanBuilder::from(plan).project(fields)?.build()?
     };
 
+    let new_columns = if !match_operations.is_empty() {
+        let mut cardinality_when = Vec::with_capacity(ops.len());
+        let mut cardinality_then = Vec::with_capacity(ops.len());
+
+        for (idx, (_, _, cardinality_class)) in ops.iter().enumerate() {
+            cardinality_when.push(lit(idx as i32));
+            cardinality_then.push(lit(*cardinality_class as i32));
+        }
+
+        let cardinality_class = CaseBuilder::new(
+            Some(Box::new(col(OPERATION_COLUMN))),
+            cardinality_when,
+            cardinality_then,
+            Some(Box::new(lit(0))),
+        )
+        .end()?;
+
+        let new_columns = DataFrame::new(state.clone(), new_columns)
+            .with_column(TARGET_MATCH_CARDINALITY_CLASS_COLUMN, cardinality_class)?
+            .into_unoptimized_plan();
+
+        LogicalPlan::Extension(Extension {
+            node: Arc::new(MergeValidation {
+                input: new_columns,
+                expr: col(TARGET_ROW_INDEX_COLUMN),
+            }),
+        })
+    } else {
+        new_columns
+    };
+
     let distribute_expr = col(file_column.as_str());
 
     let merge_barrier = LogicalPlan::Extension(Extension {
@@ -3349,6 +3448,57 @@ mod tests {
         assert!(res.is_err())
     }
 
+    #[tokio::test]
+    async fn test_merge_update_multiple_source_match_error() {
+        let schema = get_arrow_schema(&None);
+        let table = setup_table(None).await;
+        let table = write_data(table, &schema).await;
+        let ctx = SessionContext::new();
+        let batch = RecordBatch::try_new(
+            Arc::clone(&schema),
+            vec![
+                Arc::new(arrow::array::StringArray::from(vec!["B", "B"])),
+                Arc::new(arrow::array::Int32Array::from(vec![11, 12])),
+                Arc::new(arrow::array::StringArray::from(vec![
+                    "2023-07-04",
+                    "2023-07-05",
+                ])),
+            ],
+        )
+        .unwrap();
+        let source = ctx.read_batch(batch).unwrap();
+
+        let expected = vec![
+            "+----+-------+------------+",
+            "| id | value | modified   |",
+            "+----+-------+------------+",
+            "| A  | 1     | 2021-02-01 |",
+            "| B  | 10    | 2021-02-01 |",
+            "| C  | 10    | 2021-02-02 |",
+            "| D  | 100   | 2021-02-02 |",
+            "+----+-------+------------+",
+        ];
+
+        let res = table
+            .clone()
+            .merge(source, "target.id = source.id")
+            .with_source_alias("source")
+            .with_target_alias("target")
+            .when_matched_update(|update| {
+                update
+                    .update("value", "source.value")
+                    .update("modified", "source.modified")
+            })
+            .unwrap()
+            .await;
+
+        assert!(res.is_err());
+        assert_eq!(table.version(), Some(1));
+
+        let actual = get_data(&table).await;
+        assert_batches_sorted_eq!(&expected, &actual);
+    }
+
     #[tokio::test]
     async fn test_merge_partitions() {
         /* Validate the join predicate works with table partitions */
diff --git a/crates/core/src/operations/merge/validation.rs b/crates/core/src/operations/merge/validation.rs
new file mode 100644
index 000000000..006c58ac9
--- /dev/null
+++ b/crates/core/src/operations/merge/validation.rs
@@ -0,0 +1,348 @@
+use std::collections::HashMap;
+use std::pin::Pin;
+use std::sync::Arc;
+use std::task::{Context, Poll};
+
+use arrow::array::{Array, Int32Array, RecordBatch, UInt64Array};
+use arrow::datatypes::SchemaRef;
+use datafusion::common::{DataFusionError, Result as DataFusionResult};
+use datafusion::logical_expr::{Expr, LogicalPlan, UserDefinedLogicalNodeCore};
+use datafusion::physical_expr::Distribution;
+use datafusion::physical_plan::{
+    DisplayAs, DisplayFormatType, ExecutionPlan, PhysicalExpr, RecordBatchStream,
+    SendableRecordBatchStream,
+};
+use futures::{Stream, StreamExt};
+
+use crate::DeltaTableError;
+use crate::operations::merge::{
+    CardinalityClass, TARGET_MATCH_CARDINALITY_CLASS_COLUMN, TARGET_ROW_INDEX_COLUMN,
+};
+
+#[derive(Debug)]
+pub(crate) struct MergeValidationExec {
+    input: Arc<dyn ExecutionPlan>,
+    row_index_expr: Arc<dyn PhysicalExpr>,
+}
+
+impl MergeValidationExec {
+    pub fn new(input: Arc<dyn ExecutionPlan>, expr: Arc<dyn PhysicalExpr>) -> Self {
+        Self {
+            input,
+            row_index_expr: expr,
+        }
+    }
+}
+
+impl ExecutionPlan for MergeValidationExec {
+    fn name(&self) -> &str {
+        Self::static_name()
+    }
+
+    fn as_any(&self) -> &dyn std::any::Any {
+        self
+    }
+
+    fn schema(&self) -> SchemaRef {
+        self.input.schema()
+    }
+
+    fn properties(&self) -> &datafusion::physical_plan::PlanProperties {
+        self.input.properties()
+    }
+
+    fn required_input_distribution(&self) -> Vec<Distribution> {
+        vec![Distribution::HashPartitioned(vec![self.row_index_expr.clone()]); 1]
+    }
+
+    fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
+        vec![&self.input]
+    }
+
+    fn with_new_children(
+        self: Arc<Self>,
+        children: Vec<Arc<dyn ExecutionPlan>>,
+    ) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
+        if children.len() != 1 {
+            return Err(DataFusionError::Plan(
+                "MergeValidationExec wrong number of children".to_string(),
+            ));
+        }
+        Ok(Arc::new(Self::new(
+            children[0].clone(),
+            self.row_index_expr.clone(),
+        )))
+    }
+
+    fn execute(
+        &self,
+        partition: usize,
+        context: Arc<datafusion::execution::TaskContext>,
+    ) -> DataFusionResult<SendableRecordBatchStream> {
+        let input = self.input.execute(partition, context)?;
+        Ok(Box::pin(MergeValidationStream::new(input, self.schema())))
+    }
+}
+
+impl DisplayAs for MergeValidationExec {
+    fn fmt_as(&self, t: DisplayFormatType, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        match t {
+            DisplayFormatType::Default
+            | DisplayFormatType::Verbose
+            | DisplayFormatType::TreeRender => {
+                write!(f, "MergeValidation")?;
+                Ok(())
+            }
+        }
+    }
+}
+
+struct MergeValidationStream {
+    schema: SchemaRef,
+    input: SendableRecordBatchStream,
+    target_row_state: HashMap<u64, (i32, i32)>,
+}
+
+impl MergeValidationStream {
+    fn new(input: SendableRecordBatchStream, schema: SchemaRef) -> Self {
+        Self {
+            schema,
+            input,
+            target_row_state: HashMap::new(),
+        }
+    }
+
+    fn validate_batch(&mut self, batch: &RecordBatch) -> DataFusionResult<()> {
+        let target_row_index_col =
+            batch
+                .column_by_name(TARGET_ROW_INDEX_COLUMN)
+                .ok_or_else(|| {
+                    DataFusionError::External(Box::new(DeltaTableError::Generic(
+                        "Required column __delta_rs_target_row_index is missing".to_string(),
+                    )))
+                })?;
+
+        let target_row_index_array = target_row_index_col
+            .as_any()
+            .downcast_ref::<UInt64Array>()
+            .ok_or_else(|| {
+                DataFusionError::External(Box::new(DeltaTableError::Generic(
+                    "Column __delta_rs_target_row_index is not UInt64".to_string(),
+                )))
+            })?;
+
+        let cardinality_class_col = batch
+            .column_by_name(TARGET_MATCH_CARDINALITY_CLASS_COLUMN)
+            .ok_or_else(|| {
+                DataFusionError::External(Box::new(DeltaTableError::Generic(
+                    "Required column __delta_rs_match_cardinality_class is missing".to_string(),
+                )))
+            })?;
+
+        let cardinality_class_array = cardinality_class_col
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .ok_or_else(|| {
+                DataFusionError::External(Box::new(DeltaTableError::Generic(
+                    "Column __delta_rs_match_cardinality_class is not Int32".to_string(),
+                )))
+            })?;
+
+        for row in 0..batch.num_rows() {
+            if !target_row_index_array.is_null(row) && !cardinality_class_array.is_null(row) {
+                let target_row_index = target_row_index_array.value(row);
+                let cardinality_class = cardinality_class_array.value(row);
+
+                let (candidate_count, invalidating_count) = self
+                    .target_row_state
+                    .entry(target_row_index)
+                    .or_insert((0, 0));
+
+                if cardinality_class != CardinalityClass::Ignore as i32 {
+                    *candidate_count += 1;
+                }
+
+                if cardinality_class == CardinalityClass::DuplicateInvalidating as i32 {
+                    *invalidating_count += 1;
+                }
+
+                if *candidate_count > 1 && *invalidating_count > 0 {
+                    return Err(DataFusionError::External(Box::new(
+                        DeltaTableError::Generic(format!(
+                            "Merge matched a single target row (index: {}) with multiple source rows",
+                            target_row_index
+                        )),
+                    )));
+                }
+            }
+        }
+
+        Ok(())
+    }
+}
+
+impl Stream for MergeValidationStream {
+    type Item = DataFusionResult<RecordBatch>;
+
+    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
+        match self.input.poll_next_unpin(cx) {
+            Poll::Ready(Some(Ok(batch))) => {
+                if let Err(err) = self.validate_batch(&batch) {
+                    return Poll::Ready(Some(Err(err)));
+                }
+                Poll::Ready(Some(Ok(batch)))
+            }
+            Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))),
+            Poll::Ready(None) => Poll::Ready(None),
+            Poll::Pending => Poll::Pending,
+        }
+    }
+
+    fn size_hint(&self) -> (usize, Option<usize>) {
+        self.input.size_hint()
+    }
+}
+
+impl RecordBatchStream for MergeValidationStream {
+    fn schema(&self) -> SchemaRef {
+        self.schema.clone()
+    }
+}
+
+#[derive(Debug, Hash, Eq, PartialEq, PartialOrd)]
+pub(crate) struct MergeValidation {
+    pub input: LogicalPlan,
+    pub expr: Expr,
+}
+
+impl UserDefinedLogicalNodeCore for MergeValidation {
+    fn name(&self) -> &str {
+        "MergeValidation"
+    }
+
+    fn inputs(&self) -> Vec<&LogicalPlan> {
+        vec![&self.input]
+    }
+
+    fn schema(&self) -> &datafusion::common::DFSchemaRef {
+        self.input.schema()
+    }
+
+    fn expressions(&self) -> Vec<Expr> {
+        vec![self.expr.clone()]
+    }
+
+    fn fmt_for_explain(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        write!(f, "MergeValidation")
+    }
+
+    fn with_exprs_and_inputs(
+        &self,
+        exprs: Vec<datafusion::logical_expr::Expr>,
+        inputs: Vec<LogicalPlan>,
+    ) -> DataFusionResult<Self> {
+        Ok(Self {
+            input: inputs[0].clone(),
+            expr: exprs[0].clone(),
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use arrow::array::{Int32Array, RecordBatch, UInt64Array};
+    use arrow::datatypes::{DataType, Field, Schema};
+    use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
+
+    fn batch(target_indices: Vec<u64>, classes: Vec<i32>) -> RecordBatch {
+        let schema = Arc::new(Schema::new(vec![
+            Field::new(TARGET_ROW_INDEX_COLUMN, DataType::UInt64, false),
+            Field::new(
+                TARGET_MATCH_CARDINALITY_CLASS_COLUMN,
+                DataType::Int32,
+                false,
+            ),
+        ]));
+
+        RecordBatch::try_new(
+            schema,
+            vec![
+                Arc::new(UInt64Array::from(target_indices)),
+                Arc::new(Int32Array::from(classes)),
+            ],
+        )
+        .expect("failed to build test batch")
+    }
+
+    fn empty_stream(schema: SchemaRef) -> SendableRecordBatchStream {
+        let stream = futures::stream::iter(Vec::<DataFusionResult<RecordBatch>>::new());
+        Box::pin(RecordBatchStreamAdapter::new(schema, Box::pin(stream)))
+    }
+
+    fn validate(b: &RecordBatch) -> DataFusionResult<()> {
+        let mut s = MergeValidationStream::new(empty_stream(b.schema()), b.schema());
+        s.validate_batch(b)
+    }
+
+    #[test]
+    fn test_duplicate_fails() {
+        let b = batch(
+            vec![10, 10],
+            vec![
+                CardinalityClass::DuplicateInvalidating as i32,
+                CardinalityClass::DuplicateInvalidating as i32,
+            ],
+        );
+
+        let err = validate(&b).expect_err("expected duplicate violation");
+        assert!(
+            err.to_string()
+                .contains("Merge matched a single target row (index: 10)")
+        );
+    }
+
+    #[test]
+    fn test_no_duplicate_passes() {
+        let b = batch(
+            vec![10, 11],
+            vec![
+                CardinalityClass::DuplicateInvalidating as i32,
+                CardinalityClass::DuplicateInvalidating as i32,
+            ],
+        );
+
+        validate(&b).expect("should not fail for non-duplicate matches");
+    }
+
+    #[test]
+    fn test_unconditional_duplicate_deletes_passes() {
+        let b = batch(
+            vec![30, 30],
+            vec![
+                CardinalityClass::MatchedUnconditionalDelete as i32,
+                CardinalityClass::MatchedUnconditionalDelete as i32,
+            ],
+        );
+
+        validate(&b).expect("unconditional duplicate deletes should be allowed");
+    }
+
+    #[test]
+    fn test_unconditional_and_conditional_duplicate_deletes_fails() {
+        let b = batch(
+            vec![40, 40],
+            vec![
+                CardinalityClass::MatchedUnconditionalDelete as i32,
+                CardinalityClass::DuplicateInvalidating as i32,
+            ],
+        );
+
+        let err = validate(&b).expect_err("expected duplicate violation");
+        assert!(
+            err.to_string().contains(
+                "Merge matched a single target row (index: 40) with multiple source rows"
+            )
+        );
+    }
+}


Test patch:
diff --git a/python/tests/test_merge.py b/python/tests/test_merge.py
index bcdacf5c5..0e533dad0 100644
--- a/python/tests/test_merge.py
+++ b/python/tests/test_merge.py
@@ -14,6 +14,7 @@
     Schema,
     write_deltalake,
 )
+from deltalake.exceptions import DeltaError
 from deltalake.query import QueryBuilder
 from deltalake.schema import PrimitiveType
 
@@ -221,6 +222,70 @@ def test_merge_when_matched_update_wo_predicate(
     assert result == expected
 
 
+@pytest.mark.parametrize("streaming", (True, False))
+@pytest.mark.parametrize("use_update_all", (False, True))
+def test_merge_when_matched_update_duplicates(
+    tmp_path: pathlib.Path,
+    sample_table: Table,
+    streaming: bool,
+    use_update_all: bool,
+):
+    write_deltalake(tmp_path, sample_table, mode="append")
+
+    dt = DeltaTable(tmp_path)
+
+    source_table = Table(
+        {
+            "id": Array(
+                ["4", "4"],
+                ArrowField("id", type=DataType.string_view(), nullable=True),
+            ),
+            "price": Array(
+                [10, 100],
+                ArrowField("price", type=DataType.int64(), nullable=True),
+            ),
+            "sold": Array(
+                [10, 20],
+                ArrowField("sold", type=DataType.int32(), nullable=True),
+            ),
+            "deleted": Array(
+                [True, True],
+                ArrowField("deleted", type=DataType.bool(), nullable=True),
+            ),
+        }
+    )
+
+    merger = dt.merge(
+        source=source_table,
+        predicate="t.id = s.id",
+        source_alias="s",
+        target_alias="t",
+        streamed_exec=streaming,
+    )
+
+    if use_update_all:
+        merger = merger.when_matched_update_all(predicate="s.deleted = true")
+    else:
+        merger = merger.when_matched_update(
+            {"price": "s.price"},
+            predicate="s.deleted = true",
+        )
+
+    with pytest.raises(DeltaError):
+        merger.execute()
+
+    dt = DeltaTable(tmp_path)
+    result = (
+        QueryBuilder()
+        .register("tbl", dt)
+        .execute("select * from tbl order by id asc")
+        .read_all()
+    )
+
+    assert dt.version() == 0
+    assert result == sample_table
+
+
 def test_merge_when_matched_update_wo_predicate_with_schema_evolution(
     tmp_path: pathlib.Path, sample_table: Table
 ):