This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new de79604e fix(datafusion): normalize Paimon column identifiers 
consistently (#634)
de79604e is described below

commit de79604e3b00098a550042250fb058ea1419e0dc
Author: QuakeWang <[email protected]>
AuthorDate: Mon Aug 3 11:43:28 2026 +0800

    fix(datafusion): normalize Paimon column identifiers consistently (#634)
---
 crates/integrations/datafusion/src/merge_into.rs   | 266 ++++++---
 crates/integrations/datafusion/src/sql_context.rs  | 631 ++++++++++++++++++---
 crates/integrations/datafusion/src/update.rs       | 152 +++--
 .../datafusion/tests/merge_into_tests.rs           | 195 ++++++-
 crates/integrations/datafusion/tests/pk_tables.rs  |  24 +-
 .../datafusion/tests/sql_context_tests.rs          |  40 ++
 docs/src/sql.md                                    |  21 +
 7 files changed, 1115 insertions(+), 214 deletions(-)

diff --git a/crates/integrations/datafusion/src/merge_into.rs 
b/crates/integrations/datafusion/src/merge_into.rs
index 97c36079..1080eeae 100644
--- a/crates/integrations/datafusion/src/merge_into.rs
+++ b/crates/integrations/datafusion/src/merge_into.rs
@@ -35,7 +35,7 @@ use datafusion::error::{DataFusionError, Result as DFResult};
 use datafusion::prelude::{DataFrame, SessionContext};
 use datafusion::sql::sqlparser::ast::{
     AssignmentTarget, BinaryOperator, Expr as SqlExpr, Ident, Merge, 
MergeAction, MergeClauseKind,
-    MergeInsertKind, TableFactor,
+    MergeInsertExpr, MergeInsertKind, TableFactor,
 };
 use futures::TryStreamExt;
 
@@ -43,7 +43,7 @@ use paimon::spec::{datums_to_binary_row, 
extract_datum_from_arrow, CoreOptions,
 use paimon::table::{CopyOnWriteMergeWriter, DataSplitBuilder, Table, 
WriteBuilder};
 
 use crate::error::to_datafusion_error;
-use crate::sql_context::SQLContext;
+use crate::sql_context::{normalize_schema_identifier, SQLContext};
 
 /// Maximum number of retries when DML conflicts with concurrent compaction.
 const DML_MAX_RETRIES: u32 = 5;
@@ -128,14 +128,15 @@ pub(crate) async fn execute_merge_into(
     ctx: &SQLContext,
     merge: &Merge,
     table: Table,
+    enable_ident_normalization: bool,
 ) -> DFResult<DataFrame> {
     let schema = table.schema();
     let core_options = CoreOptions::new(schema.options());
 
     if core_options.data_evolution_enabled() {
-        execute_data_evolution_merge(ctx, merge, table).await
+        execute_data_evolution_merge(ctx, merge, table, 
enable_ident_normalization).await
     } else if schema.trimmed_primary_keys().is_empty() {
-        execute_cow_merge(ctx, merge, table).await
+        execute_cow_merge(ctx, merge, table, enable_ident_normalization).await
     } else {
         Err(DataFusionError::Plan(
             "MERGE INTO on primary-key tables without data-evolution is not 
supported".to_string(),
@@ -168,9 +169,10 @@ async fn execute_data_evolution_merge(
     ctx: &SQLContext,
     merge: &Merge,
     table: Table,
+    enable_ident_normalization: bool,
 ) -> DFResult<DataFrame> {
     retry_on_conflict("MERGE INTO", is_row_id_conflict, || {
-        execute_merge_into_once(ctx, merge, &table)
+        execute_merge_into_once(ctx, merge, &table, enable_ident_normalization)
     })
     .await
 }
@@ -198,7 +200,10 @@ enum CowMatchedAction {
 }
 
 /// Parse MERGE clauses for the CoW path (supports DELETE unlike the 
data-evolution parser).
-fn extract_cow_merge_clauses(merge: &Merge) -> DFResult<CowMergeClauses> {
+fn extract_cow_merge_clauses(
+    merge: &Merge,
+    enable_ident_normalization: bool,
+) -> DFResult<CowMergeClauses> {
     let mut matched: Vec<CowMatchedClause> = Vec::new();
     let mut inserts: Vec<MergeInsertClause> = Vec::new();
 
@@ -216,7 +221,12 @@ fn extract_cow_merge_clauses(merge: &Merge) -> 
DFResult<CowMergeClauses> {
                                     .0
                                     .last()
                                     .and_then(|p| p.as_ident())
-                                    .map(|id| id.value.clone())
+                                    .map(|ident| {
+                                        normalize_schema_identifier(
+                                            ident,
+                                            enable_ident_normalization,
+                                        )
+                                    })
                                     .ok_or_else(|| {
                                         DataFusionError::Plan(format!(
                                             "Invalid column name in SET: 
{name}"
@@ -253,25 +263,11 @@ fn extract_cow_merge_clauses(merge: &Merge) -> 
DFResult<CowMergeClauses> {
             MergeClauseKind::NotMatched | MergeClauseKind::NotMatchedByTarget 
=> {
                 match &clause.action {
                     MergeAction::Insert(insert_expr) => {
-                        let columns: Vec<String> =
-                            insert_expr.columns.iter().map(|c| 
c.to_string()).collect();
-                        let value_exprs = match &insert_expr.kind {
-                            MergeInsertKind::Values(values) => {
-                                if values.rows.is_empty() {
-                                    return Err(DataFusionError::Plan(
-                                        "INSERT VALUES must have at least one 
row".to_string(),
-                                    ));
-                                }
-                                values.rows[0].iter().map(|e| 
e.to_string()).collect()
-                            }
-                            MergeInsertKind::Row => Vec::new(),
-                        };
-                        let predicate = clause.predicate.as_ref().map(|p| 
p.to_string());
-                        inserts.push(MergeInsertClause {
-                            columns,
-                            value_exprs,
-                            predicate,
-                        });
+                        inserts.push(extract_merge_insert_clause(
+                            insert_expr,
+                            clause.predicate.as_ref(),
+                            enable_ident_normalization,
+                        )?);
                     }
                     _ => {
                         return Err(DataFusionError::Plan(
@@ -299,9 +295,14 @@ fn extract_cow_merge_clauses(merge: &Merge) -> 
DFResult<CowMergeClauses> {
 }
 
 /// Execute MERGE INTO on an append-only table with retry on delete conflict.
-async fn execute_cow_merge(ctx: &SQLContext, merge: &Merge, table: Table) -> 
DFResult<DataFrame> {
+async fn execute_cow_merge(
+    ctx: &SQLContext,
+    merge: &Merge,
+    table: Table,
+    enable_ident_normalization: bool,
+) -> DFResult<DataFrame> {
     retry_on_conflict("CoW MERGE INTO", is_delete_conflict, || {
-        execute_cow_merge_once(ctx, merge, &table)
+        execute_cow_merge_once(ctx, merge, &table, enable_ident_normalization)
     })
     .await
 }
@@ -311,8 +312,15 @@ async fn execute_cow_merge_once(
     ctx: &SQLContext,
     merge: &Merge,
     table: &Table,
+    enable_ident_normalization: bool,
 ) -> DFResult<DataFrame> {
-    let mut clauses = extract_cow_merge_clauses(merge)?;
+    let mut clauses = extract_cow_merge_clauses(merge, 
enable_ident_normalization)?;
+    validate_merge_insert_columns(&clauses.inserts, table.schema().fields())?;
+    for matched in &clauses.matched {
+        if let CowMatchedAction::Update(update) = &matched.action {
+            validate_update_columns(&update.columns, table.schema().fields())?;
+        }
+    }
 
     // Collect the union of all update columns across matched clauses 
(preserving order)
     let mut update_columns: Vec<String> = Vec::new();
@@ -625,9 +633,14 @@ async fn execute_merge_into_once(
     ctx: &SQLContext,
     merge: &Merge,
     table: &Table,
+    enable_ident_normalization: bool,
 ) -> DFResult<DataFrame> {
     // 1. Parse all MERGE clauses
-    let parsed = extract_merge_clauses(merge)?;
+    let parsed = extract_merge_clauses(merge, enable_ident_normalization)?;
+    validate_merge_insert_columns(&parsed.inserts, table.schema().fields())?;
+    if let Some(update) = &parsed.update {
+        validate_update_columns(&update.columns, table.schema().fields())?;
+    }
 
     // Validate preconditions early and create writer (before executing any 
SQL)
     let wb = table.new_write_builder();
@@ -988,19 +1001,18 @@ fn insert_select_clause(ins: &MergeInsertClause, 
table_fields: &[DataField]) ->
         "*".to_string()
     } else {
         // Build column_name -> expression mapping from the INSERT clause
-        let col_expr_map: HashMap<String, &str> = ins
+        let col_expr_map: HashMap<&str, &str> = ins
             .columns
             .iter()
             .zip(ins.value_exprs.iter())
-            .map(|(col, expr)| (col.to_lowercase(), expr.as_str()))
+            .map(|(col, expr)| (col.as_str(), expr.as_str()))
             .collect();
 
         // Emit SELECT in table schema order
         table_fields
             .iter()
             .map(|field| {
-                let key = field.name().to_lowercase();
-                match col_expr_map.get(&key) {
+                match col_expr_map.get(field.name()) {
                     Some(expr) => format!("{expr} AS {}", 
quote_identifier(field.name())),
                     // Column not in INSERT list — fill with NULL
                     None => format!("NULL AS {}", 
quote_identifier(field.name())),
@@ -1021,6 +1033,87 @@ struct MergeInsertClause {
     predicate: Option<String>,
 }
 
+fn extract_merge_insert_clause(
+    insert_expr: &MergeInsertExpr,
+    predicate: Option<&SqlExpr>,
+    enable_ident_normalization: bool,
+) -> DFResult<MergeInsertClause> {
+    let columns = insert_expr
+        .columns
+        .iter()
+        .map(|name| match name.0.as_slice() {
+            [part] => part
+                .as_ident()
+                .map(|ident| normalize_schema_identifier(ident, 
enable_ident_normalization))
+                .ok_or_else(|| {
+                    DataFusionError::Plan(format!("Invalid column name in 
MERGE INSERT: {name}"))
+                }),
+            _ => Err(DataFusionError::Plan(format!(
+                "Expected a simple column name in MERGE INSERT, got: {name}"
+            ))),
+        })
+        .collect::<DFResult<Vec<_>>>()?;
+
+    let value_exprs = match &insert_expr.kind {
+        MergeInsertKind::Values(values) => {
+            let row = values.rows.first().ok_or_else(|| {
+                DataFusionError::Plan("INSERT VALUES must have at least one 
row".to_string())
+            })?;
+            row.iter().map(ToString::to_string).collect()
+        }
+        // INSERT ROW — BigQuery syntax, treat as INSERT *.
+        MergeInsertKind::Row => Vec::new(),
+    };
+
+    Ok(MergeInsertClause {
+        columns,
+        value_exprs,
+        predicate: predicate.map(ToString::to_string),
+    })
+}
+
+fn validate_merge_insert_columns(
+    inserts: &[MergeInsertClause],
+    table_fields: &[DataField],
+) -> DFResult<()> {
+    for insert in inserts {
+        validate_target_columns(&insert.columns, table_fields, "MERGE 
INSERT")?;
+    }
+
+    Ok(())
+}
+
+pub(crate) fn validate_update_columns(
+    columns: &[String],
+    table_fields: &[DataField],
+) -> DFResult<()> {
+    validate_target_columns(columns, table_fields, "UPDATE")
+}
+
+fn validate_target_columns(
+    columns: &[String],
+    table_fields: &[DataField],
+    operation: &str,
+) -> DFResult<()> {
+    let target_columns: HashSet<&str> = table_fields.iter().map(|field| 
field.name()).collect();
+    let mut seen = HashSet::new();
+
+    for column in columns {
+        if !seen.insert(column.as_str()) {
+            return Err(DataFusionError::Plan(format!(
+                "Duplicate column '{column}' in {operation}"
+            )));
+        }
+        if !target_columns.contains(column.as_str()) {
+            return Err(DataFusionError::Plan(format!(
+                "Unknown column '{column}' in {operation}"
+            )));
+        }
+    }
+
+    Ok(())
+}
+
 /// Parsed WHEN MATCHED THEN UPDATE clause.
 struct MergeUpdateClause {
     columns: Vec<String>,
@@ -1035,7 +1128,10 @@ struct ParsedMergeClauses {
 }
 
 /// Extract UPDATE and INSERT clauses from the MERGE AST.
-fn extract_merge_clauses(merge: &Merge) -> DFResult<ParsedMergeClauses> {
+fn extract_merge_clauses(
+    merge: &Merge,
+    enable_ident_normalization: bool,
+) -> DFResult<ParsedMergeClauses> {
     let mut update: Option<MergeUpdateClause> = None;
     let mut delete = false;
     let mut inserts: Vec<MergeInsertClause> = Vec::new();
@@ -1063,7 +1159,12 @@ fn extract_merge_clauses(merge: &Merge) -> 
DFResult<ParsedMergeClauses> {
                                     .0
                                     .last()
                                     .and_then(|p| p.as_ident())
-                                    .map(|id| id.value.clone())
+                                    .map(|ident| {
+                                        normalize_schema_identifier(
+                                            ident,
+                                            enable_ident_normalization,
+                                        )
+                                    })
                                     .ok_or_else(|| {
                                         DataFusionError::Plan(format!(
                                             "Invalid column name in SET: 
{name}"
@@ -1094,31 +1195,11 @@ fn extract_merge_clauses(merge: &Merge) -> 
DFResult<ParsedMergeClauses> {
             MergeClauseKind::NotMatched | MergeClauseKind::NotMatchedByTarget 
=> {
                 match &clause.action {
                     MergeAction::Insert(insert_expr) => {
-                        let columns: Vec<String> =
-                            insert_expr.columns.iter().map(|c| 
c.to_string()).collect();
-
-                        let value_exprs = match &insert_expr.kind {
-                            MergeInsertKind::Values(values) => {
-                                if values.rows.is_empty() {
-                                    return Err(DataFusionError::Plan(
-                                        "INSERT VALUES must have at least one 
row".to_string(),
-                                    ));
-                                }
-                                values.rows[0].iter().map(|e| 
e.to_string()).collect()
-                            }
-                            MergeInsertKind::Row => {
-                                // INSERT ROW — BigQuery syntax, treat as 
INSERT *
-                                Vec::new()
-                            }
-                        };
-
-                        let predicate = clause.predicate.as_ref().map(|p| 
p.to_string());
-
-                        inserts.push(MergeInsertClause {
-                            columns,
-                            value_exprs,
-                            predicate,
-                        });
+                        inserts.push(extract_merge_insert_clause(
+                            insert_expr,
+                            clause.predicate.as_ref(),
+                            enable_ident_normalization,
+                        )?);
                     }
                     _ => {
                         return Err(DataFusionError::Plan(
@@ -1650,6 +1731,49 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_merge_update_assignments_normalize_identifiers() {
+        for (target, enabled, expected) in [
+            ("VALUE", true, "value"),
+            ("\"VALUE\"", true, "VALUE"),
+            ("VALUE", false, "VALUE"),
+        ] {
+            let merge = parse_merge(&format!(
+                "MERGE INTO target t USING source s ON t.id = s.id \
+                 WHEN MATCHED THEN UPDATE SET {target} = s.value"
+            ));
+
+            let parsed = extract_merge_clauses(&merge, enabled).unwrap();
+            assert_eq!(parsed.update.unwrap().columns, [expected]);
+
+            let cow = extract_cow_merge_clauses(&merge, enabled).unwrap();
+            let CowMatchedAction::Update(update) = &cow.matched[0].action else 
{
+                panic!("expected update action");
+            };
+            assert_eq!(update.columns, [expected]);
+        }
+    }
+
+    #[test]
+    fn test_merge_insert_columns_normalize_identifiers() {
+        for (target, enabled, expected) in [
+            ("MIXEDCASE", true, "mixedcase"),
+            ("\"MixedCase\"", true, "MixedCase"),
+            ("MixedCase", false, "MixedCase"),
+        ] {
+            let merge = parse_merge(&format!(
+                "MERGE INTO target t USING source s ON t.id = s.id \
+                 WHEN NOT MATCHED THEN INSERT ({target}) VALUES (s.value)"
+            ));
+
+            let parsed = extract_merge_clauses(&merge, enabled).unwrap();
+            assert_eq!(parsed.inserts[0].columns, [expected]);
+
+            let cow = extract_cow_merge_clauses(&merge, enabled).unwrap();
+            assert_eq!(cow.inserts[0].columns, [expected]);
+        }
+    }
+
     #[test]
     fn test_normalize_merge_insert_batch_uses_position() {
         let table_fields = vec![
@@ -1758,9 +1882,9 @@ mod tests {
         // Execute MERGE INTO
         let merge = parse_merge(
             "MERGE INTO paimon.test_db.t_merge t USING paimon.test_db.source s 
ON t.id = s.id \
-             WHEN MATCHED THEN UPDATE SET name = s.name",
+             WHEN MATCHED THEN UPDATE SET NAME = s.name",
         );
-        execute_merge_into(&sql_context, &merge, table)
+        execute_merge_into(&sql_context, &merge, table, true)
             .await
             .unwrap();
 
@@ -1828,7 +1952,7 @@ mod tests {
             "MERGE INTO paimon.test_db.t_merge2 t USING paimon.test_db.source 
s ON t.id = s.id \
              WHEN MATCHED THEN UPDATE SET name = s.name",
         );
-        let result = execute_merge_into(&sql_context, &merge, table)
+        let result = execute_merge_into(&sql_context, &merge, table, true)
             .await
             .unwrap();
         let batches = result.collect().await.unwrap();
@@ -1874,7 +1998,7 @@ mod tests {
             "MERGE INTO t USING s ON t.id = s.id \
              WHEN MATCHED THEN UPDATE SET id = s.id",
         );
-        let result = execute_merge_into(&sql_context, &merge, table).await;
+        let result = execute_merge_into(&sql_context, &merge, table, 
true).await;
         assert!(result.is_err());
         assert!(result
             .unwrap_err()
@@ -1958,9 +2082,9 @@ mod tests {
 
         let merge = parse_merge(
             "MERGE INTO paimon.test_db.t_cow_upd t USING paimon.test_db.source 
s ON t.id = s.id \
-             WHEN MATCHED THEN UPDATE SET name = s.name",
+             WHEN MATCHED THEN UPDATE SET NAME = s.name",
         );
-        execute_merge_into(&sql_context, &merge, table)
+        execute_merge_into(&sql_context, &merge, table, true)
             .await
             .unwrap();
 
@@ -2003,7 +2127,7 @@ mod tests {
             "MERGE INTO paimon.test_db.t_cow_del t USING paimon.test_db.source 
s ON t.id = s.id \
              WHEN MATCHED THEN DELETE",
         );
-        execute_merge_into(&sql_context, &merge, table)
+        execute_merge_into(&sql_context, &merge, table, true)
             .await
             .unwrap();
 
@@ -2042,7 +2166,7 @@ mod tests {
             "MERGE INTO paimon.test_db.t_cow_ins t USING paimon.test_db.source 
s ON t.id = s.id \
              WHEN NOT MATCHED THEN INSERT (id, name, value) VALUES (s.id, 
s.name, s.value)",
         );
-        execute_merge_into(&sql_context, &merge, table)
+        execute_merge_into(&sql_context, &merge, table, true)
             .await
             .unwrap();
 
@@ -2088,7 +2212,7 @@ mod tests {
              WHEN MATCHED THEN UPDATE SET name = s.name, value = s.value \
              WHEN NOT MATCHED THEN INSERT (id, name, value) VALUES (s.id, 
s.name, s.value)",
         );
-        execute_merge_into(&sql_context, &merge, table)
+        execute_merge_into(&sql_context, &merge, table, true)
             .await
             .unwrap();
 
@@ -2128,7 +2252,7 @@ mod tests {
             "MERGE INTO paimon.test_db.t_cow_nomatch t USING 
paimon.test_db.source s ON t.id = s.id \
              WHEN MATCHED THEN UPDATE SET name = s.name",
         );
-        let result = execute_merge_into(&sql_context, &merge, table)
+        let result = execute_merge_into(&sql_context, &merge, table, true)
             .await
             .unwrap();
         let batches = result.collect().await.unwrap();
diff --git a/crates/integrations/datafusion/src/sql_context.rs 
b/crates/integrations/datafusion/src/sql_context.rs
index 1803892b..cd54a08e 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -62,7 +62,7 @@ use datafusion::sql::planner::IdentNormalizer;
 use datafusion::sql::sqlparser::ast::{
     AlterColumnOperation, AlterTableOperation, BinaryLength, CharacterLength, 
ColumnDef,
     ColumnOption, CreateFunction, CreateFunctionBody, CreateTable, 
CreateTableOptions, CreateView,
-    Delete, Expr as SqlExpr, FromTable, FunctionBehavior, FunctionReturnType, 
Insert, Merge,
+    Delete, Expr as SqlExpr, FromTable, FunctionBehavior, FunctionReturnType, 
Ident, Insert, Merge,
     ObjectName, ObjectType, RenameTableNameKind, Reset, ResetStatement, Set, 
ShowCreateObject,
     SqlOption, Statement, TableFactor, TableObject, Truncate, Update, Use, 
Value as SqlValue,
 };
@@ -368,8 +368,9 @@ impl SQLContext {
     /// directly; everything else is delegated to DataFusion.
     pub async fn sql(&self, sql: &str) -> DFResult<DataFrame> {
         let is_create_table = looks_like_create_table(sql);
+        let enable_ident_normalization = self.ctx.enable_ident_normalization();
         let (rewritten_sql, partition_keys) = if is_create_table {
-            extract_partition_by(sql)?
+            extract_partition_by(sql, enable_ident_normalization)?
         } else {
             (sql.to_string(), vec![])
         };
@@ -435,8 +436,13 @@ impl SQLContext {
                 } else {
                     let (catalog, _catalog_name, _) =
                         self.resolve_catalog_and_table(&create_table.name)?;
-                    self.handle_create_table(&catalog, create_table, 
partition_keys)
-                        .await
+                    self.handle_create_table(
+                        &catalog,
+                        create_table,
+                        partition_keys,
+                        enable_ident_normalization,
+                    )
+                    .await
                 }
             }
             Statement::ShowCreate {
@@ -451,17 +457,24 @@ impl SQLContext {
                     &alter_table.name,
                     &alter_table.operations,
                     alter_table.if_exists,
+                    enable_ident_normalization,
                 )
                 .await
             }
-            Statement::Merge(merge) => self.handle_merge_into(merge).await,
-            Statement::Update(update) => self.handle_update(update).await,
+            Statement::Merge(merge) => {
+                self.handle_merge_into(merge, enable_ident_normalization)
+                    .await
+            }
+            Statement::Update(update) => {
+                self.handle_update(update, enable_ident_normalization).await
+            }
             Statement::Delete(delete) => self.handle_delete(delete).await,
             Statement::Insert(insert)
                 if insert.overwrite
                     && insert.partitioned.as_ref().is_some_and(|p| 
!p.is_empty()) =>
             {
-                self.handle_insert_overwrite_partition(insert).await
+                self.handle_insert_overwrite_partition(insert, 
enable_ident_normalization)
+                    .await
             }
             Statement::Set(Set::SingleAssignment {
                 variable, values, ..
@@ -497,7 +510,10 @@ impl SQLContext {
                 }
                 self.ctx.sql(sql).await
             }
-            Statement::Truncate(truncate) => 
self.handle_truncate_table(truncate).await,
+            Statement::Truncate(truncate) => {
+                self.handle_truncate_table(truncate, 
enable_ident_normalization)
+                    .await
+            }
             Statement::CreateView(create_view) => {
                 if create_view.temporary {
                     // Temporary views are always handled by us (Paimon 
catalog temp storage)
@@ -845,6 +861,7 @@ impl SQLContext {
         catalog: &Arc<dyn Catalog>,
         ct: &CreateTable,
         partition_keys: Vec<String>,
+        enable_ident_normalization: bool,
     ) -> DFResult<DataFrame> {
         if ct.external {
             return Err(DataFusionError::Plan(
@@ -866,12 +883,17 @@ impl SQLContext {
 
         let mut builder = paimon::spec::Schema::builder();
         let table_options = extract_options(&ct.table_options)?;
+        let column_names: Vec<String> = ct
+            .columns
+            .iter()
+            .map(|column| normalize_schema_identifier(&column.name, 
enable_ident_normalization))
+            .collect();
 
         // Columns
-        for col in &ct.columns {
+        for (col, column_name) in ct.columns.iter().zip(&column_names) {
             let paimon_type = column_def_to_paimon_type(col)?;
             let comment = column_def_comment(col);
-            builder = builder.column_with_description(col.name.value.clone(), 
paimon_type, comment);
+            builder = builder.column_with_description(column_name.clone(), 
paimon_type, comment);
         }
 
         // Primary key from constraints: PRIMARY KEY (col, ...)
@@ -880,7 +902,7 @@ impl SQLContext {
                 let pk_cols: Vec<String> = pk
                     .columns
                     .iter()
-                    .map(|c| primary_key_column_name(&c.column.expr))
+                    .map(|c| primary_key_column_name(&c.column.expr, 
enable_ident_normalization))
                     .collect();
                 builder = builder.primary_key(pk_cols);
             }
@@ -888,9 +910,8 @@ impl SQLContext {
 
         // Partition keys (extracted and validated before parsing)
         if !partition_keys.is_empty() {
-            let col_names: Vec<&str> = ct.columns.iter().map(|c| 
c.name.value.as_str()).collect();
             for pk in &partition_keys {
-                if !col_names.contains(&pk.as_str()) {
+                if !column_names.contains(pk) {
                     return Err(DataFusionError::Plan(format!(
                         "PARTITIONED BY column '{pk}' is not defined in the 
table"
                     )));
@@ -1164,6 +1185,7 @@ impl SQLContext {
         name: &ObjectName,
         operations: &[AlterTableOperation],
         if_exists: bool,
+        enable_ident_normalization: bool,
     ) -> DFResult<DataFrame> {
         Self::ensure_main_branch_write_target(name, "ALTER TABLE")?;
         let identifier = self.resolve_table_name(name)?;
@@ -1188,11 +1210,17 @@ impl SQLContext {
         for op in operations {
             match op {
                 AlterTableOperation::AddColumn { column_def, .. } => {
-                    changes.push(column_def_to_add_column(column_def)?);
+                    changes.push(column_def_to_add_column(
+                        column_def,
+                        enable_ident_normalization,
+                    )?);
                 }
                 AlterTableOperation::DropColumn { column_names, .. } => {
                     for col in column_names {
-                        
changes.push(SchemaChange::drop_column(col.value.clone()));
+                        
changes.push(SchemaChange::drop_column(normalize_schema_identifier(
+                            col,
+                            enable_ident_normalization,
+                        )));
                     }
                 }
                 AlterTableOperation::RenameColumn {
@@ -1200,12 +1228,16 @@ impl SQLContext {
                     new_column_name,
                 } => {
                     changes.push(SchemaChange::rename_column(
-                        old_column_name.value.clone(),
-                        new_column_name.value.clone(),
+                        normalize_schema_identifier(old_column_name, 
enable_ident_normalization),
+                        normalize_schema_identifier(new_column_name, 
enable_ident_normalization),
                     ));
                 }
                 AlterTableOperation::AlterColumn { column_name, op } => {
-                    
changes.push(alter_column_to_schema_change(&column_name.value, op)?);
+                    changes.push(alter_column_to_schema_change(
+                        column_name,
+                        op,
+                        enable_ident_normalization,
+                    )?);
                 }
                 AlterTableOperation::RenameTable { table_name } => {
                     let new_name = match table_name {
@@ -1238,6 +1270,7 @@ impl SQLContext {
                             &identifier,
                             partitions,
                             if_exists || *partition_if_exists,
+                            enable_ident_normalization,
                         )
                         .await;
                 }
@@ -1298,7 +1331,11 @@ impl SQLContext {
         Ok(())
     }
 
-    async fn handle_merge_into(&self, merge: &Merge) -> DFResult<DataFrame> {
+    async fn handle_merge_into(
+        &self,
+        merge: &Merge,
+        enable_ident_normalization: bool,
+    ) -> DFResult<DataFrame> {
         self.ensure_no_time_travel_for_write("MERGE INTO")?;
         let table_name = match &merge.table {
             TableFactor::Table { name, .. } => name.clone(),
@@ -1316,10 +1353,14 @@ impl SQLContext {
             .await
             .map_err(to_datafusion_error)?;
 
-        crate::merge_into::execute_merge_into(self, merge, table).await
+        crate::merge_into::execute_merge_into(self, merge, table, 
enable_ident_normalization).await
     }
 
-    async fn handle_update(&self, update: &Update) -> DFResult<DataFrame> {
+    async fn handle_update(
+        &self,
+        update: &Update,
+        enable_ident_normalization: bool,
+    ) -> DFResult<DataFrame> {
         self.ensure_no_time_travel_for_write("UPDATE")?;
         let table_name = match &update.table.relation {
             TableFactor::Table { name, .. } => name.clone(),
@@ -1337,7 +1378,7 @@ impl SQLContext {
             .await
             .map_err(to_datafusion_error)?;
 
-        crate::update::execute_update(self, update, table).await
+        crate::update::execute_update(self, update, table, 
enable_ident_normalization).await
     }
 
     async fn handle_delete(&self, delete: &Delete) -> DFResult<DataFrame> {
@@ -1369,7 +1410,11 @@ impl SQLContext {
         crate::delete::execute_delete(self, delete, table, &table_ref).await
     }
 
-    async fn handle_insert_overwrite_partition(&self, insert: &Insert) -> 
DFResult<DataFrame> {
+    async fn handle_insert_overwrite_partition(
+        &self,
+        insert: &Insert,
+        enable_ident_normalization: bool,
+    ) -> DFResult<DataFrame> {
         self.ensure_no_time_travel_for_write("INSERT OVERWRITE")?;
         let table_name = match &insert.table {
             TableObject::TableName(name) => name.clone(),
@@ -1390,8 +1435,12 @@ impl SQLContext {
             DataFusionError::Plan("INSERT OVERWRITE PARTITION requires a 
PARTITION clause".into())
         })?;
         let partition_fields = table.schema().partition_fields();
-        let static_partitions =
-            parse_static_partitions(partition_exprs, &partition_fields, 
table.schema().fields())?;
+        let static_partitions = parse_static_partitions(
+            partition_exprs,
+            &partition_fields,
+            table.schema().fields(),
+            enable_ident_normalization,
+        )?;
 
         let source = insert.source.as_ref().ok_or_else(|| {
             DataFusionError::Plan("INSERT OVERWRITE requires a source 
query".into())
@@ -1412,7 +1461,7 @@ impl SQLContext {
                 insert
                     .columns
                     .iter()
-                    .map(object_name_to_single_identifier)
+                    .map(|name| object_name_to_single_identifier(name, 
enable_ident_normalization))
                     .collect::<DFResult<_>>()?,
             )
         } else if !insert.after_columns.is_empty() {
@@ -1420,7 +1469,7 @@ impl SQLContext {
                 insert
                     .after_columns
                     .iter()
-                    .map(|ident| ident.value.clone())
+                    .map(|ident| normalize_schema_identifier(ident, 
enable_ident_normalization))
                     .collect(),
             )
         } else {
@@ -1514,7 +1563,11 @@ impl SQLContext {
         crate::merge_into::ok_result(&self.ctx, row_count)
     }
 
-    async fn handle_truncate_table(&self, truncate: &Truncate) -> 
DFResult<DataFrame> {
+    async fn handle_truncate_table(
+        &self,
+        truncate: &Truncate,
+        enable_ident_normalization: bool,
+    ) -> DFResult<DataFrame> {
         self.ensure_no_time_travel_for_write("TRUNCATE TABLE")?;
         if truncate.table_names.len() > 1 {
             return Err(DataFusionError::Plan(
@@ -1547,6 +1600,7 @@ impl SQLContext {
                 partitions,
                 table.schema().fields(),
                 table.schema().partition_keys(),
+                enable_ident_normalization,
             )?;
             commit
                 .truncate_partitions(partition_values)
@@ -1803,6 +1857,7 @@ impl SQLContext {
         identifier: &Identifier,
         partitions: &[SqlExpr],
         if_exists: bool,
+        enable_ident_normalization: bool,
     ) -> DFResult<DataFrame> {
         if partitions.is_empty() {
             return Err(DataFusionError::Plan(
@@ -1821,6 +1876,7 @@ impl SQLContext {
             partitions,
             table.schema().fields(),
             table.schema().partition_keys(),
+            enable_ident_normalization,
         )?;
 
         let wb = table.new_write_builder();
@@ -2406,7 +2462,7 @@ fn find_partitioned_by(sql: &str) -> Option<(usize, 
usize)> {
 }
 
 /// Parse a single partition column token, handling quoted identifiers.
-fn parse_partition_column(token: &str) -> DFResult<String> {
+fn parse_partition_column(token: &str, enable_ident_normalization: bool) -> 
DFResult<String> {
     let trimmed = token.trim();
     if trimmed.is_empty() {
         return Err(DataFusionError::Plan(
@@ -2434,7 +2490,10 @@ fn parse_partition_column(token: &str) -> 
DFResult<String> {
         }
         if let Some(end) = end {
             if trimmed[end..].trim().is_empty() {
-                return Ok(value);
+                return Ok(normalize_schema_identifier(
+                    &Ident::with_quote(first as char, value),
+                    enable_ident_normalization,
+                ));
             }
         }
         return Err(DataFusionError::Plan(format!(
@@ -2444,7 +2503,10 @@ fn parse_partition_column(token: &str) -> 
DFResult<String> {
 
     let parts: Vec<&str> = trimmed.split_whitespace().collect();
     match parts.len() {
-        1 => Ok(parts[0].to_string()),
+        1 => Ok(normalize_schema_identifier(
+            &Ident::new(parts[0]),
+            enable_ident_normalization,
+        )),
         _ => Err(DataFusionError::Plan(format!(
             "PARTITIONED BY column '{}' should not specify a type. \
              Use column references only, e.g. PARTITIONED BY ({})",
@@ -2491,7 +2553,10 @@ fn split_partition_columns(inner: &str) -> 
DFResult<Vec<&str>> {
 /// Since sqlparser's GenericDialect requires types in column definitions,
 /// we extract and validate the clause ourselves, then strip it from the SQL
 /// so sqlparser can parse the rest.
-fn extract_partition_by(sql: &str) -> DFResult<(String, Vec<String>)> {
+fn extract_partition_by(
+    sql: &str,
+    enable_ident_normalization: bool,
+) -> DFResult<(String, Vec<String>)> {
     let Some((kw_start, by_end)) = find_partitioned_by(sql) else {
         return Ok((sql.to_string(), vec![]));
     };
@@ -2545,7 +2610,7 @@ fn extract_partition_by(sql: &str) -> DFResult<(String, 
Vec<String>)> {
 
     let mut partition_keys = Vec::new();
     for token in split_partition_columns(inner)? {
-        partition_keys.push(parse_partition_column(token)?);
+        partition_keys.push(parse_partition_column(token, 
enable_ident_normalization)?);
     }
 
     let clause_end = paren_end + 1;
@@ -2555,13 +2620,26 @@ fn extract_partition_by(sql: &str) -> DFResult<(String, 
Vec<String>)> {
     Ok((rewritten, partition_keys))
 }
 
+pub(crate) fn normalize_schema_identifier(
+    identifier: &Ident,
+    enable_ident_normalization: bool,
+) -> String {
+    
IdentNormalizer::new(enable_ident_normalization).normalize(identifier.clone())
+}
+
 /// Convert a sqlparser [`ColumnDef`] to a Paimon [`SchemaChange::AddColumn`].
-fn column_def_to_add_column(col: &ColumnDef) -> DFResult<SchemaChange> {
+fn column_def_to_add_column(
+    col: &ColumnDef,
+    enable_ident_normalization: bool,
+) -> DFResult<SchemaChange> {
     let paimon_type = column_def_to_paimon_type(col)?;
     let comment = column_def_comment(col);
 
     Ok(SchemaChange::AddColumn {
-        field_names: vec![col.name.value.clone()],
+        field_names: vec![normalize_schema_identifier(
+            &col.name,
+            enable_ident_normalization,
+        )],
         data_type: paimon_type,
         comment,
         column_move: None,
@@ -2569,18 +2647,19 @@ fn column_def_to_add_column(col: &ColumnDef) -> 
DFResult<SchemaChange> {
 }
 
 fn alter_column_to_schema_change(
-    column_name: &str,
+    column_name: &Ident,
     operation: &AlterColumnOperation,
+    enable_ident_normalization: bool,
 ) -> DFResult<SchemaChange> {
+    let column_name = normalize_schema_identifier(column_name, 
enable_ident_normalization);
+
     match operation {
-        AlterColumnOperation::SetNotNull => 
Ok(SchemaChange::update_column_nullability(
-            column_name.to_string(),
-            false,
-        )),
-        AlterColumnOperation::DropNotNull => 
Ok(SchemaChange::update_column_nullability(
-            column_name.to_string(),
-            true,
-        )),
+        AlterColumnOperation::SetNotNull => {
+            Ok(SchemaChange::update_column_nullability(column_name, false))
+        }
+        AlterColumnOperation::DropNotNull => {
+            Ok(SchemaChange::update_column_nullability(column_name, true))
+        }
         AlterColumnOperation::SetDataType {
             data_type, using, ..
         } => {
@@ -2591,7 +2670,7 @@ fn alter_column_to_schema_change(
             }
             let new_data_type = sql_data_type_to_paimon_type(data_type, true)?;
             Ok(SchemaChange::UpdateColumnType {
-                field_names: vec![column_name.to_string()],
+                field_names: vec![column_name],
                 new_data_type,
                 // A type-only SQL change must not change the column's 
nullability.
                 keep_nullability: true,
@@ -2614,9 +2693,11 @@ fn column_def_comment(col: &ColumnDef) -> Option<String> 
{
     })
 }
 
-fn primary_key_column_name(expr: &SqlExpr) -> String {
+fn primary_key_column_name(expr: &SqlExpr, enable_ident_normalization: bool) 
-> String {
     match expr {
-        SqlExpr::Identifier(ident) => ident.value.clone(),
+        SqlExpr::Identifier(ident) => {
+            normalize_schema_identifier(ident, enable_ident_normalization)
+        }
         _ => expr.to_string(),
     }
 }
@@ -2829,11 +2910,14 @@ fn object_name_to_string(name: &ObjectName) -> String {
         .join(".")
 }
 
-fn object_name_to_single_identifier(name: &ObjectName) -> DFResult<String> {
+fn object_name_to_single_identifier(
+    name: &ObjectName,
+    enable_ident_normalization: bool,
+) -> DFResult<String> {
     match name.0.as_slice() {
         [part] => part
             .as_ident()
-            .map(|id| id.value.clone())
+            .map(|ident| normalize_schema_identifier(ident, 
enable_ident_normalization))
             .ok_or_else(|| DataFusionError::Plan(format!("Invalid column name: 
{name}"))),
         _ => Err(DataFusionError::Plan(format!(
             "Expected a simple column name, got: {name}"
@@ -2883,11 +2967,13 @@ fn parse_partition_values(
     exprs: &[SqlExpr],
     all_fields: &[PaimonDataField],
     partition_keys: &[String],
+    enable_ident_normalization: bool,
 ) -> DFResult<Vec<HashMap<String, Option<Datum>>>> {
     let field_map: HashMap<&str, &PaimonDataField> =
         all_fields.iter().map(|f| (f.name(), f)).collect();
 
     let mut partition = HashMap::new();
+    let mut seen_columns = HashSet::new();
     for expr in exprs {
         let (col_name, val_expr) = match expr {
             SqlExpr::BinaryOp {
@@ -2896,7 +2982,9 @@ fn parse_partition_values(
                 right,
             } => {
                 let col = match left.as_ref() {
-                    SqlExpr::Identifier(ident) => ident.value.clone(),
+                    SqlExpr::Identifier(ident) => {
+                        normalize_schema_identifier(ident, 
enable_ident_normalization)
+                    }
                     other => {
                         return Err(DataFusionError::Plan(format!(
                             "Expected column name in partition spec, got: 
{other}"
@@ -2912,6 +3000,11 @@ fn parse_partition_values(
             }
         };
 
+        if !seen_columns.insert(col_name.clone()) {
+            return Err(DataFusionError::Plan(format!(
+                "Duplicate partition column '{col_name}'"
+            )));
+        }
         if !partition_keys.iter().any(|k| k == &col_name) {
             return Err(DataFusionError::Plan(format!(
                 "Column '{col_name}' is not a partition column"
@@ -2947,8 +3040,10 @@ fn parse_static_partitions(
     exprs: &[SqlExpr],
     partition_fields: &[PaimonDataField],
     all_fields: &[PaimonDataField],
+    enable_ident_normalization: bool,
 ) -> DFResult<HashMap<String, Option<Datum>>> {
     let mut result = HashMap::new();
+    let mut seen_columns = HashSet::new();
     let field_map: HashMap<&str, &PaimonDataField> =
         all_fields.iter().map(|f| (f.name(), f)).collect();
     let partition_names: Vec<&str> = partition_fields.iter().map(|f| 
f.name()).collect();
@@ -2961,25 +3056,20 @@ fn parse_static_partitions(
                 right,
             } => {
                 let col = match left.as_ref() {
-                    SqlExpr::Identifier(ident) => ident.value.clone(),
+                    SqlExpr::Identifier(ident) => {
+                        normalize_schema_identifier(ident, 
enable_ident_normalization)
+                    }
                     other => {
                         return Err(DataFusionError::Plan(format!(
                             "Expected column name in PARTITION clause, got: 
{other}"
                         )))
                     }
                 };
-                (col, right.as_ref())
+                (col, Some(right.as_ref()))
             }
-            // Dynamic partition: bare column name without value — skip it,
-            // the column will be read from the source query.
             SqlExpr::Identifier(ident) => {
-                let col_name = &ident.value;
-                if !partition_names.contains(&col_name.as_str()) {
-                    return Err(DataFusionError::Plan(format!(
-                        "Column '{col_name}' is not a partition column"
-                    )));
-                }
-                continue;
+                let col = normalize_schema_identifier(ident, 
enable_ident_normalization);
+                (col, None)
             }
             other => {
                 return Err(DataFusionError::Plan(format!(
@@ -2988,12 +3078,21 @@ fn parse_static_partitions(
             }
         };
 
+        if !seen_columns.insert(col_name.clone()) {
+            return Err(DataFusionError::Plan(format!(
+                "Duplicate partition column '{col_name}'"
+            )));
+        }
         if !partition_names.contains(&col_name.as_str()) {
             return Err(DataFusionError::Plan(format!(
                 "Column '{col_name}' is not a partition column"
             )));
         }
 
+        // Dynamic partition columns are read from the source query.
+        let Some(val_expr) = val_expr else {
+            continue;
+        };
         let field = field_map.get(col_name.as_str()).ok_or_else(|| {
             DataFusionError::Plan(format!("Column '{col_name}' not found in 
table schema"))
         })?;
@@ -6137,6 +6236,50 @@ mod tests {
         }
     }
 
+    #[tokio::test]
+    async fn test_create_table_normalizes_schema_identifiers() {
+        let catalog = Arc::new(MockCatalog::new());
+        let sql_context = make_sql_context(catalog.clone()).await;
+
+        sql_context
+            .sql(
+                "CREATE TABLE mydb.t1 (
+                    ID INT NOT NULL,
+                    PART STRING,
+                    PRIMARY KEY (ID)
+                ) PARTITIONED BY (PART)",
+            )
+            .await
+            .unwrap();
+
+        let calls = catalog.take_calls();
+        let [CatalogCall::CreateTable { schema, .. }] = calls.as_slice() else {
+            panic!("expected one CreateTable call, got {calls:?}");
+        };
+        let field_names: Vec<&str> = schema.fields().iter().map(|field| 
field.name()).collect();
+        assert_eq!(field_names, ["id", "part"]);
+        assert_eq!(schema.primary_keys(), ["id"]);
+        assert_eq!(schema.partition_keys(), ["part"]);
+    }
+
+    #[tokio::test]
+    async fn test_create_table_preserves_quoted_schema_identifiers() {
+        let catalog = Arc::new(MockCatalog::new());
+        let sql_context = make_sql_context(catalog.clone()).await;
+
+        sql_context
+            .sql("CREATE TABLE mydb.t1 (\"ID\" INT NOT NULL, PRIMARY KEY 
(\"ID\"))")
+            .await
+            .unwrap();
+
+        let calls = catalog.take_calls();
+        let [CatalogCall::CreateTable { schema, .. }] = calls.as_slice() else {
+            panic!("expected one CreateTable call, got {calls:?}");
+        };
+        assert_eq!(schema.fields()[0].name(), "ID");
+        assert_eq!(schema.primary_keys(), ["ID"]);
+    }
+
     #[tokio::test]
     async fn test_create_table_if_not_exists() {
         let catalog = Arc::new(MockCatalog::new());
@@ -6284,7 +6427,7 @@ mod tests {
         let sql_context = make_sql_context(catalog.clone()).await;
 
         sql_context
-            .sql("ALTER TABLE mydb.t1 ADD COLUMN age INT")
+            .sql("ALTER TABLE mydb.t1 ADD COLUMN AGE INT")
             .await
             .unwrap();
 
@@ -6370,7 +6513,7 @@ mod tests {
         let sql_context = make_sql_context(catalog.clone()).await;
 
         sql_context
-            .sql("ALTER TABLE mydb.t1 DROP COLUMN age")
+            .sql("ALTER TABLE mydb.t1 DROP COLUMN AGE")
             .await
             .unwrap();
 
@@ -6392,7 +6535,7 @@ mod tests {
         let sql_context = make_sql_context(catalog.clone()).await;
 
         sql_context
-            .sql("ALTER TABLE mydb.t1 RENAME COLUMN old_name TO new_name")
+            .sql("ALTER TABLE mydb.t1 RENAME COLUMN OLD_NAME TO NEW_NAME")
             .await
             .unwrap();
 
@@ -6416,8 +6559,8 @@ mod tests {
         let sql_context = make_sql_context(catalog.clone()).await;
 
         for sql in [
-            "ALTER TABLE mydb.t1 ALTER COLUMN value TYPE BIGINT",
-            "ALTER TABLE mydb.t1 ALTER COLUMN value SET DATA TYPE BIGINT",
+            "ALTER TABLE mydb.t1 ALTER COLUMN VALUE TYPE BIGINT",
+            "ALTER TABLE mydb.t1 ALTER COLUMN VALUE SET DATA TYPE BIGINT",
         ] {
             sql_context.sql(sql).await.unwrap();
 
@@ -6445,8 +6588,8 @@ mod tests {
         let sql_context = make_sql_context(catalog.clone()).await;
 
         for (sql, expected_nullability) in [
-            ("ALTER TABLE mydb.t1 ALTER COLUMN value SET NOT NULL", false),
-            ("ALTER TABLE mydb.t1 ALTER COLUMN value DROP NOT NULL", true),
+            ("ALTER TABLE mydb.t1 ALTER COLUMN VALUE SET NOT NULL", false),
+            ("ALTER TABLE mydb.t1 ALTER COLUMN VALUE DROP NOT NULL", true),
         ] {
             sql_context.sql(sql).await.unwrap();
 
@@ -6484,11 +6627,64 @@ mod tests {
             panic!("expected ALTER COLUMN operation");
         };
 
-        let err = alter_column_to_schema_change(&column_name.value, 
op).unwrap_err();
+        let err = alter_column_to_schema_change(column_name, op, 
true).unwrap_err();
 
         assert!(err.to_string().contains("USING is not supported"));
     }
 
+    #[tokio::test]
+    async fn test_alter_table_preserves_quoted_schema_identifier() {
+        let catalog = Arc::new(MockCatalog::new());
+        let sql_context = make_sql_context(catalog.clone()).await;
+
+        sql_context
+            .sql("ALTER TABLE mydb.t1 ALTER COLUMN \"MixedCase\" TYPE BIGINT")
+            .await
+            .unwrap();
+
+        let calls = catalog.take_calls();
+        let [CatalogCall::AlterTable { changes, .. }] = calls.as_slice() else {
+            panic!("expected one AlterTable call, got {calls:?}");
+        };
+        assert!(matches!(
+            &changes[0],
+            SchemaChange::UpdateColumnType { field_names, .. }
+                if field_names == &["MixedCase"]
+        ));
+    }
+
+    #[tokio::test]
+    async fn test_schema_identifier_normalization_can_be_disabled() {
+        let catalog = Arc::new(MockCatalog::new());
+        let sql_context = make_sql_context(catalog.clone()).await;
+
+        sql_context
+            .sql("SET datafusion.sql_parser.enable_ident_normalization = 
false")
+            .await
+            .unwrap();
+        sql_context
+            .sql("CREATE TABLE mydb.t1 (VALUE INT)")
+            .await
+            .unwrap();
+        sql_context
+            .sql("ALTER TABLE mydb.t1 ALTER COLUMN VALUE TYPE BIGINT")
+            .await
+            .unwrap();
+
+        let calls = catalog.take_calls();
+        let [CatalogCall::CreateTable { schema, .. }, CatalogCall::AlterTable 
{ changes, .. }] =
+            calls.as_slice()
+        else {
+            panic!("expected CreateTable and AlterTable calls, got {calls:?}");
+        };
+        assert_eq!(schema.fields()[0].name(), "VALUE");
+        assert!(matches!(
+            &changes[0],
+            SchemaChange::UpdateColumnType { field_names, .. }
+                if field_names == &["VALUE"]
+        ));
+    }
+
     #[tokio::test]
     async fn test_alter_table_update_column_default_is_unsupported() {
         let catalog = Arc::new(MockCatalog::new());
@@ -6687,7 +6883,7 @@ mod tests {
 
     #[test]
     fn test_extract_partition_by_no_clause() {
-        let (rewritten, keys) = extract_partition_by("CREATE TABLE t (id 
INT)").unwrap();
+        let (rewritten, keys) = extract_partition_by("CREATE TABLE t (id 
INT)", true).unwrap();
         assert_eq!(rewritten, "CREATE TABLE t (id INT)");
         assert!(keys.is_empty());
     }
@@ -6696,6 +6892,7 @@ mod tests {
     fn test_extract_partition_by_single_column() {
         let (rewritten, keys) = extract_partition_by(
             "CREATE TABLE t (id INT, dt STRING) PARTITIONED BY (dt) WITH 
('k'='v')",
+            true,
         )
         .unwrap();
         assert_eq!(keys, vec!["dt"]);
@@ -6705,35 +6902,65 @@ mod tests {
 
     #[test]
     fn test_extract_partition_by_multiple_columns() {
+        let (_, keys) = extract_partition_by(
+            "CREATE TABLE t (a INT, b INT, c INT) PARTITIONED BY (a, b)",
+            true,
+        )
+        .unwrap();
+        assert_eq!(keys, vec!["a", "b"]);
+    }
+
+    #[test]
+    fn test_extract_partition_by_normalizes_identifiers() {
+        let (_, keys) =
+            extract_partition_by("CREATE TABLE t (PART INT) PARTITIONED BY 
(PART)", true).unwrap();
+        assert_eq!(keys, ["part"]);
+
+        let (_, keys) = extract_partition_by(
+            "CREATE TABLE t (\"PART\" INT) PARTITIONED BY (\"PART\")",
+            true,
+        )
+        .unwrap();
+        assert_eq!(keys, ["PART"]);
+
         let (_, keys) =
-            extract_partition_by("CREATE TABLE t (a INT, b INT, c INT) 
PARTITIONED BY (a, b)")
+            extract_partition_by("CREATE TABLE t (`PART` INT) PARTITIONED BY 
(`PART`)", true)
                 .unwrap();
-        assert_eq!(keys, vec!["a", "b"]);
+        assert_eq!(keys, ["PART"]);
+
+        let (_, keys) =
+            extract_partition_by("CREATE TABLE t (PART INT) PARTITIONED BY 
(PART)", false).unwrap();
+        assert_eq!(keys, ["PART"]);
     }
 
     #[test]
     fn test_extract_partition_by_mixed_case() {
         let (_, keys) =
-            extract_partition_by("CREATE TABLE t (dt INT) Partitioned by 
(dt)").unwrap();
+            extract_partition_by("CREATE TABLE t (dt INT) Partitioned by 
(dt)", true).unwrap();
         assert_eq!(keys, vec!["dt"]);
     }
 
     #[test]
     fn test_extract_partition_by_rejects_typed_column() {
-        let err = extract_partition_by("CREATE TABLE t (dt STRING) PARTITIONED 
BY (dt STRING)")
-            .unwrap_err();
+        let err = extract_partition_by(
+            "CREATE TABLE t (dt STRING) PARTITIONED BY (dt STRING)",
+            true,
+        )
+        .unwrap_err();
         assert!(err.to_string().contains("should not specify a type"));
     }
 
     #[test]
     fn test_extract_partition_by_empty_parens() {
-        let err = extract_partition_by("CREATE TABLE t (id INT) PARTITIONED BY 
()").unwrap_err();
+        let err =
+            extract_partition_by("CREATE TABLE t (id INT) PARTITIONED BY ()", 
true).unwrap_err();
         assert!(err.to_string().contains("at least one column"));
     }
 
     #[test]
     fn test_extract_partition_by_unmatched_paren() {
-        let err = extract_partition_by("CREATE TABLE t (id INT) PARTITIONED BY 
(dt").unwrap_err();
+        let err =
+            extract_partition_by("CREATE TABLE t (id INT) PARTITIONED BY (dt", 
true).unwrap_err();
         assert!(err.to_string().contains("Unmatched"));
     }
 
@@ -6741,7 +6968,7 @@ mod tests {
     fn test_extract_partition_by_skips_string_literal() {
         let sql =
             "CREATE TABLE t (id INT) WITH ('note' = 'PARTITIONED BY (x)') 
PARTITIONED BY (id)";
-        let (rewritten, keys) = extract_partition_by(sql).unwrap();
+        let (rewritten, keys) = extract_partition_by(sql, true).unwrap();
         assert_eq!(keys, vec!["id"]);
         assert!(rewritten.contains("WITH"));
         assert!(rewritten.contains("'PARTITIONED BY (x)'"));
@@ -6750,15 +6977,17 @@ mod tests {
     #[test]
     fn test_extract_partition_by_skips_line_comment() {
         let sql = "CREATE TABLE t (id INT) -- PARTITIONED BY (x)\nPARTITIONED 
BY (id)";
-        let (_, keys) = extract_partition_by(sql).unwrap();
+        let (_, keys) = extract_partition_by(sql, true).unwrap();
         assert_eq!(keys, vec!["id"]);
     }
 
     #[test]
     fn test_extract_partition_by_double_quoted_identifier() {
-        let (_, keys) =
-            extract_partition_by("CREATE TABLE t (\"order\" INT) PARTITIONED 
BY (\"order\")")
-                .unwrap();
+        let (_, keys) = extract_partition_by(
+            "CREATE TABLE t (\"order\" INT) PARTITIONED BY (\"order\")",
+            true,
+        )
+        .unwrap();
         assert_eq!(keys, vec!["order"]);
     }
 
@@ -6767,6 +6996,7 @@ mod tests {
         let (_, keys) = extract_partition_by(
             "CREATE TABLE t (\"a\"\"b,c\" INT, `d``e,f` INT) \
              PARTITIONED BY (\"a\"\"b,c\", `d``e,f`)",
+            true,
         )
         .unwrap();
         assert_eq!(keys, vec!["a\"b,c", "d`e,f"]);
@@ -6774,20 +7004,25 @@ mod tests {
 
     #[test]
     fn test_extract_partition_by_backtick_quoted_identifier() {
-        let (_, keys) =
-            extract_partition_by("CREATE TABLE t (`order` INT) PARTITIONED BY 
(`order`)").unwrap();
+        let (_, keys) = extract_partition_by(
+            "CREATE TABLE t (`order` INT) PARTITIONED BY (`order`)",
+            true,
+        )
+        .unwrap();
         assert_eq!(keys, vec!["order"]);
     }
 
     #[test]
     fn test_extract_partition_by_no_paren_after_by() {
-        let err = extract_partition_by("CREATE TABLE t (id INT) PARTITIONED BY 
dt").unwrap_err();
+        let err =
+            extract_partition_by("CREATE TABLE t (id INT) PARTITIONED BY dt", 
true).unwrap_err();
         assert!(err.to_string().contains("Expected '('"));
     }
 
     #[test]
     fn test_extract_partition_by_only_partitioned_no_by() {
-        let (rewritten, keys) = extract_partition_by("CREATE TABLE partitioned 
(id INT)").unwrap();
+        let (rewritten, keys) =
+            extract_partition_by("CREATE TABLE partitioned (id INT)", 
true).unwrap();
         assert_eq!(rewritten, "CREATE TABLE partitioned (id INT)");
         assert!(keys.is_empty());
     }
@@ -6795,7 +7030,7 @@ mod tests {
     #[test]
     fn test_extract_partition_by_skips_block_comment() {
         let sql = "CREATE TABLE t (id INT) /* PARTITIONED BY (x) */ 
PARTITIONED BY (id)";
-        let (rewritten, keys) = extract_partition_by(sql).unwrap();
+        let (rewritten, keys) = extract_partition_by(sql, true).unwrap();
         assert_eq!(keys, vec!["id"]);
         assert!(rewritten.contains("/* PARTITIONED BY (x) */"));
     }
@@ -6966,6 +7201,66 @@ mod tests {
         (temp_dir, sql_context)
     }
 
+    async fn collect_partition_rows(
+        sql_context: &SQLContext,
+        table: &str,
+        partition_column: &str,
+        value_column: &str,
+    ) -> Vec<(String, i32)> {
+        let batches = sql_context
+            .sql(&format!(
+                "SELECT {partition_column}, {value_column} FROM {table} \
+                 ORDER BY {partition_column}"
+            ))
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+
+        let mut rows = Vec::new();
+        for batch in batches {
+            let partitions = batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<StringViewArray>()
+                .unwrap();
+            let values = batch
+                .column(1)
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap();
+            for row in 0..batch.num_rows() {
+                rows.push((partitions.value(row).to_string(), 
values.value(row)));
+            }
+        }
+        rows
+    }
+
+    async fn setup_duplicate_partition_table() -> (tempfile::TempDir, 
SQLContext) {
+        let (tmp, sql_context) = setup_fs_sql_context().await;
+        sql_context
+            .sql(
+                "CREATE TABLE paimon.test_db.duplicate_partition_keys (
+                    dt VARCHAR,
+                    value INT
+                ) PARTITIONED BY (dt)",
+            )
+            .await
+            .unwrap();
+        sql_context
+            .sql(
+                "INSERT INTO paimon.test_db.duplicate_partition_keys
+                 VALUES ('a', 1), ('b', 2)",
+            )
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        (tmp, sql_context)
+    }
+
     #[tokio::test]
     async fn test_dynamic_read_batch_size_overrides_table_option() {
         let (_tmp, sql_context) = setup_fs_sql_context().await;
@@ -7061,7 +7356,7 @@ mod tests {
         let (_tmp, sql_context) = setup_fs_sql_context().await;
 
         sql_context
-            .sql("CREATE TABLE paimon.test_db.t2 (pt VARCHAR, id INT) 
PARTITIONED BY (pt)")
+            .sql("CREATE TABLE paimon.test_db.t2 (PT VARCHAR, ID INT) 
PARTITIONED BY (PT)")
             .await
             .unwrap();
         sql_context
@@ -7073,7 +7368,7 @@ mod tests {
             .unwrap();
 
         sql_context
-            .sql("TRUNCATE TABLE paimon.test_db.t2 PARTITION (pt = 'a')")
+            .sql("TRUNCATE TABLE paimon.test_db.t2 PARTITION (PT = 'a')")
             .await
             .unwrap();
 
@@ -7109,7 +7404,7 @@ mod tests {
         let (_tmp, sql_context) = setup_fs_sql_context().await;
 
         sql_context
-            .sql("CREATE TABLE paimon.test_db.t3 (pt VARCHAR, id INT) 
PARTITIONED BY (pt)")
+            .sql("CREATE TABLE paimon.test_db.t3 (PT VARCHAR, ID INT) 
PARTITIONED BY (PT)")
             .await
             .unwrap();
         sql_context
@@ -7121,7 +7416,7 @@ mod tests {
             .unwrap();
 
         sql_context
-            .sql("ALTER TABLE paimon.test_db.t3 DROP PARTITION (pt = 'b')")
+            .sql("ALTER TABLE paimon.test_db.t3 DROP PARTITION (PT = 'b')")
             .await
             .unwrap();
 
@@ -7152,6 +7447,160 @@ mod tests {
         assert_eq!(rows, vec![("a".to_string(), 1), ("a".to_string(), 2)]);
     }
 
+    #[tokio::test]
+    async fn test_partition_removal_rejects_normalized_duplicate_keys() {
+        for sql in [
+            "TRUNCATE TABLE paimon.test_db.duplicate_partition_keys
+             PARTITION (DT = 'a', dt = 'b')",
+            "ALTER TABLE paimon.test_db.duplicate_partition_keys
+             DROP PARTITION (DT = 'a', dt = 'b')",
+        ] {
+            let (_tmp, sql_context) = setup_duplicate_partition_table().await;
+
+            let err = sql_context.sql(sql).await.unwrap_err();
+            assert!(
+                err.to_string().contains("Duplicate partition column 'dt'"),
+                "Expected duplicate partition column error, got: {err}"
+            );
+            assert_eq!(
+                collect_partition_rows(
+                    &sql_context,
+                    "paimon.test_db.duplicate_partition_keys",
+                    "dt",
+                    "value",
+                )
+                .await,
+                [("a".to_string(), 1), ("b".to_string(), 2)]
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn test_insert_overwrite_rejects_duplicate_partition_keys() {
+        for sql in [
+            "INSERT OVERWRITE paimon.test_db.duplicate_partition_keys
+             PARTITION (DT = 'a', dt = 'b') VALUES (9)",
+            "INSERT OVERWRITE paimon.test_db.duplicate_partition_keys
+             PARTITION (DT = 'a', dt) VALUES (9)",
+        ] {
+            let (_tmp, sql_context) = setup_duplicate_partition_table().await;
+
+            let err = sql_context.sql(sql).await.unwrap_err();
+            assert!(
+                err.to_string().contains("Duplicate partition column 'dt'"),
+                "Expected duplicate partition column error, got: {err}"
+            );
+            assert_eq!(
+                collect_partition_rows(
+                    &sql_context,
+                    "paimon.test_db.duplicate_partition_keys",
+                    "dt",
+                    "value",
+                )
+                .await,
+                [("a".to_string(), 1), ("b".to_string(), 2)]
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn test_partition_writes_preserve_quoted_identifiers() {
+        let (_tmp, sql_context) = setup_fs_sql_context().await;
+        let table = "paimon.test_db.quoted_partition_writes";
+
+        sql_context
+            .sql(&format!(
+                "CREATE TABLE {table} (
+                    \"PART\" VARCHAR,
+                    \"VALUE\" INT
+                ) PARTITIONED BY (\"PART\")"
+            ))
+            .await
+            .unwrap();
+        sql_context
+            .sql(&format!("INSERT INTO {table} VALUES ('x', 1), ('y', 10)"))
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+
+        sql_context
+            .sql(&format!(
+                "INSERT OVERWRITE {table} (\"VALUE\") \
+                 PARTITION (\"PART\" = 'x') VALUES (2)"
+            ))
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        sql_context
+            .sql(&format!(
+                "UPDATE {table} SET \"VALUE\" = 3 WHERE \"PART\" = 'x'"
+            ))
+            .await
+            .unwrap();
+        sql_context
+            .sql(&format!(
+                "TRUNCATE TABLE {table} PARTITION (\"PART\" = 'y')"
+            ))
+            .await
+            .unwrap();
+
+        assert_eq!(
+            collect_partition_rows(&sql_context, table, "\"PART\"", 
"\"VALUE\"").await,
+            [("x".to_string(), 3)]
+        );
+    }
+
+    #[tokio::test]
+    async fn test_partition_writes_respect_disabled_normalization() {
+        let (_tmp, sql_context) = setup_fs_sql_context().await;
+        let table = "paimon.test_db.preserved_partition_writes";
+
+        sql_context
+            .sql("SET datafusion.sql_parser.enable_ident_normalization = 
false")
+            .await
+            .unwrap();
+        sql_context
+            .sql(&format!(
+                "CREATE TABLE {table} (
+                    PART VARCHAR,
+                    VALUE INT
+                ) PARTITIONED BY (PART)"
+            ))
+            .await
+            .unwrap();
+        sql_context
+            .sql(&format!("INSERT INTO {table} VALUES ('x', 1)"))
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+
+        sql_context
+            .sql(&format!(
+                "INSERT OVERWRITE {table} (VALUE) \
+                 PARTITION (PART = 'x') VALUES (2)"
+            ))
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        sql_context
+            .sql(&format!("UPDATE {table} SET VALUE = 3 WHERE PART = 'x'"))
+            .await
+            .unwrap();
+
+        assert_eq!(
+            collect_partition_rows(&sql_context, table, "PART", "VALUE").await,
+            [("x".to_string(), 3)]
+        );
+    }
+
     #[tokio::test]
     async fn test_truncate_table_incomplete_partition_spec() {
         let (_tmp, sql_context) = setup_fs_sql_context().await;
diff --git a/crates/integrations/datafusion/src/update.rs 
b/crates/integrations/datafusion/src/update.rs
index 5362b09a..e8693ed1 100644
--- a/crates/integrations/datafusion/src/update.rs
+++ b/crates/integrations/datafusion/src/update.rs
@@ -37,15 +37,16 @@ use crate::error::to_datafusion_error;
 use crate::merge_into::{
     build_partition_set_from_where, extract_tracking_columns, 
is_delete_conflict,
     is_row_id_conflict, ok_result, project_update_columns, quote_identifier,
-    register_cow_target_table, retry_on_conflict, TempTableTracker,
+    register_cow_target_table, retry_on_conflict, validate_update_columns, 
TempTableTracker,
 };
-use crate::sql_context::SQLContext;
+use crate::sql_context::{normalize_schema_identifier, SQLContext};
 
 /// Execute an UPDATE statement on a Paimon table.
 pub(crate) async fn execute_update(
     ctx: &SQLContext,
     update: &Update,
     table: Table,
+    enable_ident_normalization: bool,
 ) -> DFResult<DataFrame> {
     if let TableFactor::Table { alias: Some(a), .. } = &update.table.relation {
         return Err(DataFusionError::Plan(format!(
@@ -58,9 +59,9 @@ pub(crate) async fn execute_update(
     let core_options = CoreOptions::new(schema.options());
 
     if core_options.data_evolution_enabled() {
-        execute_data_evolution_update(ctx, update, table).await
+        execute_data_evolution_update(ctx, update, table, 
enable_ident_normalization).await
     } else if schema.trimmed_primary_keys().is_empty() {
-        execute_cow_update(ctx, update, &table).await
+        execute_cow_update(ctx, update, &table, 
enable_ident_normalization).await
     } else {
         Err(DataFusionError::Plan(
             "UPDATE on primary-key tables without data-evolution is not 
supported".to_string(),
@@ -77,9 +78,10 @@ async fn execute_data_evolution_update(
     ctx: &SQLContext,
     update: &Update,
     table: Table,
+    enable_ident_normalization: bool,
 ) -> DFResult<DataFrame> {
     retry_on_conflict("UPDATE", is_row_id_conflict, || {
-        execute_update_once(ctx, update, &table)
+        execute_update_once(ctx, update, &table, enable_ident_normalization)
     })
     .await
 }
@@ -89,29 +91,11 @@ async fn execute_update_once(
     ctx: &SQLContext,
     update: &Update,
     table: &Table,
+    enable_ident_normalization: bool,
 ) -> DFResult<DataFrame> {
     // 1. Extract SET assignments
-    let mut columns = Vec::new();
-    let mut exprs = Vec::new();
-    for assignment in &update.assignments {
-        let col_name = match &assignment.target {
-            AssignmentTarget::ColumnName(name) => name
-                .0
-                .last()
-                .and_then(|p| p.as_ident())
-                .map(|id| id.value.clone())
-                .ok_or_else(|| {
-                    DataFusionError::Plan(format!("Invalid column name in SET: 
{name}"))
-                })?,
-            AssignmentTarget::Tuple(_) => {
-                return Err(DataFusionError::Plan(
-                    "Tuple assignment in UPDATE SET is not 
supported".to_string(),
-                ));
-            }
-        };
-        columns.push(col_name);
-        exprs.push(assignment.value.to_string());
-    }
+    let (columns, exprs) = extract_set_assignments(update, 
enable_ident_normalization)?;
+    validate_update_columns(&columns, table.schema().fields())?;
 
     // 2. Create TableUpdate through the table write builder (validates 
preconditions)
     let wb = table.new_write_builder();
@@ -176,9 +160,10 @@ async fn execute_cow_update(
     ctx: &SQLContext,
     update: &Update,
     table: &Table,
+    enable_ident_normalization: bool,
 ) -> DFResult<DataFrame> {
     retry_on_conflict("CoW UPDATE", is_delete_conflict, || {
-        execute_cow_update_once(ctx, update, table)
+        execute_cow_update_once(ctx, update, table, enable_ident_normalization)
     })
     .await
 }
@@ -188,8 +173,10 @@ async fn execute_cow_update_once(
     ctx: &SQLContext,
     update: &Update,
     table: &Table,
+    enable_ident_normalization: bool,
 ) -> DFResult<DataFrame> {
-    let (columns, exprs) = extract_set_assignments(update)?;
+    let (columns, exprs) = extract_set_assignments(update, 
enable_ident_normalization)?;
+    validate_update_columns(&columns, table.schema().fields())?;
 
     let table_ref = update.table.to_string();
     let where_str = update.selection.as_ref().map(|e| e.to_string());
@@ -305,7 +292,10 @@ async fn execute_cow_update_inner(
 }
 
 /// Extract column names and expressions from UPDATE SET assignments.
-fn extract_set_assignments(update: &Update) -> DFResult<(Vec<String>, 
Vec<String>)> {
+fn extract_set_assignments(
+    update: &Update,
+    enable_ident_normalization: bool,
+) -> DFResult<(Vec<String>, Vec<String>)> {
     let mut columns = Vec::new();
     let mut exprs = Vec::new();
     for assignment in &update.assignments {
@@ -314,7 +304,7 @@ fn extract_set_assignments(update: &Update) -> 
DFResult<(Vec<String>, Vec<String
                 .0
                 .last()
                 .and_then(|p| p.as_ident())
-                .map(|id| id.value.clone())
+                .map(|ident| normalize_schema_identifier(ident, 
enable_ident_normalization))
                 .ok_or_else(|| {
                     DataFusionError::Plan(format!("Invalid column name in SET: 
{name}"))
                 })?,
@@ -401,6 +391,70 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_extract_set_assignments_normalizes_identifiers() {
+        for (sql, enabled, expected) in [
+            ("UPDATE t SET VALUE = 1", true, "value"),
+            ("UPDATE t SET \"VALUE\" = 1", true, "VALUE"),
+            ("UPDATE t SET VALUE = 1", false, "VALUE"),
+        ] {
+            let update = parse_update(sql);
+            let (columns, _) = extract_set_assignments(&update, 
enabled).unwrap();
+            assert_eq!(columns, [expected]);
+        }
+    }
+
+    #[tokio::test]
+    async fn test_update_rejects_unknown_and_duplicate_target_columns() {
+        let (_tmp, sql_context, _catalog) = setup_sql_context().await;
+
+        for (table_name, options) in [
+            ("t_update_columns_cow", ""),
+            (
+                "t_update_columns_data_evolution",
+                "WITH (
+                    'data-evolution.enabled' = 'true',
+                    'row-tracking.enabled' = 'true'
+                )",
+            ),
+        ] {
+            sql_context
+                .sql(&format!(
+                    "CREATE TABLE paimon.test_db.{table_name} (
+                        id INT,
+                        value INT
+                    ) {options}"
+                ))
+                .await
+                .unwrap();
+            sql_context
+                .sql(&format!(
+                    "INSERT INTO paimon.test_db.{table_name} (id, value) 
VALUES (1, 10)"
+                ))
+                .await
+                .unwrap()
+                .collect()
+                .await
+                .unwrap();
+
+            for (assignments, expected_error) in [
+                ("\"VALUE\" = 3", "Unknown column 'VALUE' in UPDATE"),
+                ("value = 3, VALUE = 4", "Duplicate column 'value' in UPDATE"),
+            ] {
+                let err = sql_context
+                    .sql(&format!(
+                        "UPDATE paimon.test_db.{table_name} SET {assignments}"
+                    ))
+                    .await
+                    .unwrap_err();
+                assert!(
+                    err.to_string().contains(expected_error),
+                    "Error '{err}' does not contain '{expected_error}'"
+                );
+            }
+        }
+    }
+
     fn collect_rows(batches: &[datafusion::arrow::array::RecordBatch]) -> 
Vec<(i32, String, i32)> {
         let mut rows = Vec::new();
         for batch in batches {
@@ -432,7 +486,9 @@ mod tests {
 
         let update =
             parse_update("UPDATE paimon.test_db.t_with_where SET name = 
'ALICE' WHERE id = 1");
-        execute_update(&sql_context, &update, table).await.unwrap();
+        execute_update(&sql_context, &update, table, true)
+            .await
+            .unwrap();
 
         let batches = sql_context
             .sql("SELECT id, name, value FROM paimon.test_db.t_with_where 
ORDER BY id")
@@ -457,8 +513,10 @@ mod tests {
     async fn test_update_without_where() {
         let (_tmp, sql_context, table) = 
setup_data_evolution_table("t_without_where").await;
 
-        let update = parse_update("UPDATE paimon.test_db.t_without_where SET 
value = 99");
-        execute_update(&sql_context, &update, table).await.unwrap();
+        let update = parse_update("UPDATE paimon.test_db.t_without_where SET 
VALUE = 99");
+        execute_update(&sql_context, &update, table, true)
+            .await
+            .unwrap();
 
         let batches = sql_context
             .sql("SELECT id, name, value FROM paimon.test_db.t_without_where 
ORDER BY id")
@@ -486,7 +544,9 @@ mod tests {
         let update = parse_update(
             "UPDATE paimon.test_db.t_multi_col SET name = 'updated', value = 0 
WHERE id = 2",
         );
-        execute_update(&sql_context, &update, table).await.unwrap();
+        execute_update(&sql_context, &update, table, true)
+            .await
+            .unwrap();
 
         let batches = sql_context
             .sql("SELECT id, name, value FROM paimon.test_db.t_multi_col ORDER 
BY id")
@@ -513,7 +573,9 @@ mod tests {
 
         let update =
             parse_update("UPDATE paimon.test_db.t_no_match SET name = 'nobody' 
WHERE id = 99");
-        let result = execute_update(&sql_context, &update, 
table).await.unwrap();
+        let result = execute_update(&sql_context, &update, table, true)
+            .await
+            .unwrap();
         let batches = result.collect().await.unwrap();
         let count = batches[0]
             .column(0)
@@ -538,7 +600,9 @@ mod tests {
             .unwrap();
 
         let update = parse_update("UPDATE paimon.test_db.t_row_id SET name = 
'ALICE' WHERE id = 1");
-        execute_update(&sql_context, &update, table).await.unwrap();
+        execute_update(&sql_context, &update, table, true)
+            .await
+            .unwrap();
 
         // Get row IDs after update
         let after = sql_context
@@ -583,7 +647,7 @@ mod tests {
 
         let sql_context = SQLContext::new();
         let update = parse_update("UPDATE t SET id = 1");
-        let result = execute_update(&sql_context, &update, table).await;
+        let result = execute_update(&sql_context, &update, table, true).await;
         assert!(result.is_err());
         assert!(result
             .unwrap_err()
@@ -653,10 +717,20 @@ mod tests {
     async fn test_cow_update_without_where() {
         let (_tmp, sql_context) = 
setup_append_only_table("t_cow_no_where").await;
 
-        sql_context
-            .sql("UPDATE paimon.test_db.t_cow_no_where SET value = 99")
+        let result = sql_context
+            .sql("UPDATE paimon.test_db.t_cow_no_where SET VALUE = 99")
+            .await
+            .unwrap()
+            .collect()
             .await
             .unwrap();
+        let count = result[0]
+            .column(0)
+            .as_any()
+            .downcast_ref::<UInt64Array>()
+            .unwrap()
+            .value(0);
+        assert_eq!(count, 3);
 
         let rows = query_rows(&sql_context, 
"paimon.test_db.t_cow_no_where").await;
         assert_eq!(
diff --git a/crates/integrations/datafusion/tests/merge_into_tests.rs 
b/crates/integrations/datafusion/tests/merge_into_tests.rs
index 87fb50be..4598b9a7 100644
--- a/crates/integrations/datafusion/tests/merge_into_tests.rs
+++ b/crates/integrations/datafusion/tests/merge_into_tests.rs
@@ -25,7 +25,7 @@ mod common;
 
 use std::sync::Arc;
 
-use arrow_array::{Int32Array, Int64Array};
+use arrow_array::{Array, Int32Array, Int64Array};
 use common::string_value;
 use paimon::catalog::Identifier;
 use paimon::table::SnapshotManager;
@@ -35,6 +35,17 @@ use tempfile::TempDir;
 
 // ======================= Helpers =======================
 
+const MERGE_TABLE_CONFIGS: [(&str, &str); 2] = [
+    ("merge_ident_cow", ""),
+    (
+        "merge_ident_data_evolution",
+        "WITH (
+            'data-evolution.enabled' = 'true',
+            'row-tracking.enabled' = 'true'
+        )",
+    ),
+];
+
 fn create_test_env() -> (TempDir, Arc<FileSystemCatalog>) {
     let temp_dir = TempDir::new().expect("Failed to create temp dir");
     let warehouse = format!("file://{}", temp_dir.path().display());
@@ -138,8 +149,190 @@ async fn register_source(sql_context: &SQLContext, sql: 
&str) {
     sql_context.sql(sql).await.unwrap().collect().await.unwrap();
 }
 
+async fn assert_merge_insert_identifier(
+    table_column: &str,
+    insert_column: &str,
+    query_column: &str,
+    enable_ident_normalization: bool,
+) {
+    let (_tmp, catalog) = create_test_env();
+    let sql_context = create_sql_context(catalog).await;
+
+    if !enable_ident_normalization {
+        sql_context
+            .sql("SET datafusion.sql_parser.enable_ident_normalization = 
false")
+            .await
+            .unwrap();
+    }
+
+    sql_context
+        .sql("CREATE SCHEMA paimon.test_db")
+        .await
+        .unwrap();
+    register_source(
+        &sql_context,
+        "CREATE TEMPORARY TABLE paimon.test_db.merge_ident_source \
+         AS SELECT * FROM (VALUES (1, 42)) AS t(id, source_value)",
+    )
+    .await;
+
+    for (table_name, options) in MERGE_TABLE_CONFIGS {
+        sql_context
+            .sql(&format!(
+                "CREATE TABLE paimon.test_db.{table_name} (
+                    id INT NOT NULL,
+                    {table_column} INT
+                ) {options}"
+            ))
+            .await
+            .unwrap();
+
+        sql_context
+            .sql(&format!(
+                "MERGE INTO paimon.test_db.{table_name} t
+                 USING paimon.test_db.merge_ident_source s ON t.id = s.id
+                 WHEN NOT MATCHED THEN
+                 INSERT (id, {insert_column}) VALUES (s.id, s.source_value)"
+            ))
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+
+        let batches = sql_context
+            .sql(&format!(
+                "SELECT {query_column} FROM paimon.test_db.{table_name}"
+            ))
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+        assert_eq!(
+            batches.iter().map(|batch| batch.num_rows()).sum::<usize>(),
+            1
+        );
+
+        let values = batches[0]
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        assert!(!values.is_null(0), "{table_name} wrote NULL");
+        assert_eq!(values.value(0), 42, "{table_name} wrote the wrong value");
+    }
+}
+
 // ======================= Functional Tests =======================
 
+#[tokio::test]
+async fn test_merge_insert_preserves_quoted_identifier() {
+    assert_merge_insert_identifier("\"MixedCase\"", "\"MixedCase\"", 
"\"MixedCase\"", true).await;
+}
+
+#[tokio::test]
+async fn test_merge_insert_normalizes_unquoted_identifier_by_default() {
+    assert_merge_insert_identifier("MIXEDCASE", "MIXEDCASE", "mixedcase", 
true).await;
+}
+
+#[tokio::test]
+async fn test_merge_insert_respects_disabled_identifier_normalization() {
+    assert_merge_insert_identifier("MixedCase", "MixedCase", "\"MixedCase\"", 
false).await;
+}
+
+#[tokio::test]
+async fn test_merge_insert_rejects_unknown_and_duplicate_columns() {
+    let (_tmp, catalog) = create_test_env();
+    let sql_context = create_sql_context(catalog).await;
+
+    sql_context
+        .sql("CREATE SCHEMA paimon.test_db")
+        .await
+        .unwrap();
+    register_source(
+        &sql_context,
+        "CREATE TEMPORARY TABLE paimon.test_db.merge_ident_source \
+         AS SELECT * FROM (VALUES (1, 42)) AS t(id, source_value)",
+    )
+    .await;
+
+    for (table_name, options) in MERGE_TABLE_CONFIGS {
+        sql_context
+            .sql(&format!(
+                "CREATE TABLE paimon.test_db.{table_name} (
+                    id INT NOT NULL,
+                    value INT
+                ) {options}"
+            ))
+            .await
+            .unwrap();
+
+        for (columns, expected_error) in [
+            ("id, missing", "Unknown column 'missing' in MERGE INSERT"),
+            ("id, ID", "Duplicate column 'id' in MERGE INSERT"),
+        ] {
+            let sql = format!(
+                "MERGE INTO paimon.test_db.{table_name} t
+                 USING paimon.test_db.merge_ident_source s ON t.id = s.id
+                 WHEN NOT MATCHED THEN
+                 INSERT ({columns}) VALUES (s.id, s.source_value)"
+            );
+            assert_merge_error(&sql_context, &sql, expected_error).await;
+        }
+    }
+}
+
+#[tokio::test]
+async fn test_merge_update_rejects_unknown_and_duplicate_target_columns() {
+    let (_tmp, catalog) = create_test_env();
+    let sql_context = create_sql_context(catalog).await;
+
+    sql_context
+        .sql("CREATE SCHEMA paimon.test_db")
+        .await
+        .unwrap();
+    register_source(
+        &sql_context,
+        "CREATE TEMPORARY TABLE paimon.test_db.merge_update_source \
+         AS SELECT * FROM (VALUES (1)) AS t(id)",
+    )
+    .await;
+
+    for (table_name, options) in MERGE_TABLE_CONFIGS {
+        sql_context
+            .sql(&format!(
+                "CREATE TABLE paimon.test_db.{table_name} (
+                    id INT,
+                    value INT
+                ) {options}"
+            ))
+            .await
+            .unwrap();
+        sql_context
+            .sql(&format!(
+                "INSERT INTO paimon.test_db.{table_name} (id, value) VALUES 
(1, 10)"
+            ))
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+
+        for (assignments, expected_error) in [
+            ("\"VALUE\" = 3", "Unknown column 'VALUE' in UPDATE"),
+            ("value = 3, VALUE = 4", "Duplicate column 'value' in UPDATE"),
+        ] {
+            let sql = format!(
+                "MERGE INTO paimon.test_db.{table_name} t
+                 USING paimon.test_db.merge_update_source s ON t.id = s.id
+                 WHEN MATCHED THEN UPDATE SET {assignments}"
+            );
+            assert_merge_error(&sql_context, &sql, expected_error).await;
+        }
+    }
+}
+
 #[tokio::test]
 async fn test_row_id_values_after_insert() {
     let (_tmp, catalog) = create_test_env();
diff --git a/crates/integrations/datafusion/tests/pk_tables.rs 
b/crates/integrations/datafusion/tests/pk_tables.rs
index 39e25972..0f44c819 100644
--- a/crates/integrations/datafusion/tests/pk_tables.rs
+++ b/crates/integrations/datafusion/tests/pk_tables.rs
@@ -961,9 +961,9 @@ async fn test_pk_insert_overwrite_with_partition_clause() {
     sql_context
         .sql(
             "CREATE TABLE paimon.test_db.t_ow_part (
-                dt STRING, id INT NOT NULL, name STRING,
-                PRIMARY KEY (dt, id)
-            ) PARTITIONED BY (dt)
+                DT STRING, ID INT NOT NULL, NAME STRING,
+                PRIMARY KEY (DT, ID)
+            ) PARTITIONED BY (DT)
             WITH ('bucket' = '1')",
         )
         .await
@@ -986,7 +986,7 @@ async fn test_pk_insert_overwrite_with_partition_clause() {
     // The SELECT only provides non-partition columns (id, name).
     sql_context
         .sql(
-            "INSERT OVERWRITE paimon.test_db.t_ow_part PARTITION (dt = 
'2024-01-01') \
+            "INSERT OVERWRITE paimon.test_db.t_ow_part PARTITION (DT = 
'2024-01-01') \
              VALUES (10, 'new_alice'), (20, 'new_bob')",
         )
         .await
@@ -1236,9 +1236,9 @@ async fn 
test_pk_insert_overwrite_dynamic_partition_preserves_other_partitions()
     sql_context
         .sql(
             "CREATE TABLE paimon.test_db.t_dyn (
-                dt STRING, id INT NOT NULL, name STRING,
-                PRIMARY KEY (dt, id)
-            ) PARTITIONED BY (dt)
+                DT STRING, ID INT NOT NULL, NAME STRING,
+                PRIMARY KEY (DT, ID)
+            ) PARTITIONED BY (DT)
             WITH ('bucket' = '1')",
         )
         .await
@@ -1259,7 +1259,7 @@ async fn 
test_pk_insert_overwrite_dynamic_partition_preserves_other_partitions()
     // Should only overwrite partitions present in the source data.
     sql_context
         .sql(
-            "INSERT OVERWRITE paimon.test_db.t_dyn PARTITION (dt) \
+            "INSERT OVERWRITE paimon.test_db.t_dyn PARTITION (DT) \
              VALUES ('2024-01-01', 10, 'new_alice')",
         )
         .await
@@ -1354,9 +1354,9 @@ async fn 
test_pk_insert_overwrite_with_after_columns_reorder() {
     sql_context
         .sql(
             "CREATE TABLE paimon.test_db.t_reorder (
-                dt STRING, id INT NOT NULL, name STRING,
-                PRIMARY KEY (dt, id)
-            ) PARTITIONED BY (dt)
+                DT STRING, ID INT NOT NULL, NAME STRING,
+                PRIMARY KEY (DT, ID)
+            ) PARTITIONED BY (DT)
             WITH ('bucket' = '1')",
         )
         .await
@@ -1365,7 +1365,7 @@ async fn 
test_pk_insert_overwrite_with_after_columns_reorder() {
     // Insert with columns in reversed order: (name, id) instead of schema 
order (id, name)
     sql_context
         .sql(
-            "INSERT OVERWRITE paimon.test_db.t_reorder (name, id) PARTITION 
(dt = '2024-01-01') \
+            "INSERT OVERWRITE paimon.test_db.t_reorder (NAME, ID) PARTITION 
(dt = '2024-01-01') \
              VALUES ('alice', 1), ('bob', 2)",
         )
         .await
diff --git a/crates/integrations/datafusion/tests/sql_context_tests.rs 
b/crates/integrations/datafusion/tests/sql_context_tests.rs
index 308b806c..b1f37d85 100644
--- a/crates/integrations/datafusion/tests/sql_context_tests.rs
+++ b/crates/integrations/datafusion/tests/sql_context_tests.rs
@@ -1317,6 +1317,46 @@ async fn 
test_alter_table_update_column_type_and_nullability() {
     assert!(table.schema().fields()[1].data_type().is_nullable());
 }
 
+#[tokio::test]
+async fn test_alter_column_preserves_quoted_identifier() {
+    let (_tmp, catalog) = create_test_env();
+    let sql_context = create_sql_context(catalog.clone()).await;
+
+    sql_context
+        .sql("CREATE SCHEMA paimon.mydb")
+        .await
+        .expect("CREATE SCHEMA should succeed");
+    sql_context
+        .sql(
+            "CREATE TABLE paimon.mydb.quoted_column (
+                id INT,
+                \"MixedCase\" INT
+            )",
+        )
+        .await
+        .expect("CREATE TABLE should preserve the quoted column name");
+
+    sql_context
+        .sql(
+            "ALTER TABLE paimon.mydb.quoted_column
+             ALTER COLUMN \"MixedCase\" TYPE BIGINT",
+        )
+        .await
+        .expect("ALTER COLUMN should resolve the quoted column name");
+
+    let table = catalog
+        .get_table(&Identifier::new("mydb", "quoted_column"))
+        .await
+        .unwrap();
+    let field = table
+        .schema()
+        .fields()
+        .iter()
+        .find(|field| field.name() == "MixedCase")
+        .expect("quoted column should retain its exact name");
+    assert!(matches!(field.data_type(), DataType::BigInt(_)));
+}
+
 #[tokio::test]
 async fn test_alter_table_rename() {
     let (_tmp, catalog) = create_test_env();
diff --git a/docs/src/sql.md b/docs/src/sql.md
index b0da06bf..208e0322 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -578,6 +578,27 @@ CREATE TABLE IF NOT EXISTS paimon.my_db.users (
 );
 ```
 
+Top-level column identifiers in persistent Paimon DDL follow
+`datafusion.sql_parser.enable_ident_normalization`. With the default value
+`true`, unquoted identifiers are stored in lowercase, while quoted identifiers
+preserve their spelling. This applies consistently to column definitions,
+primary keys, partition keys, and `ALTER TABLE` column operations.
+
+```sql
+CREATE TABLE paimon.my_db.unquoted_example (ID INT);   -- stores `id`
+CREATE TABLE paimon.my_db.quoted_example ("ID" INT);   -- stores `ID`
+```
+
+Set the option to `false` to preserve unquoted spelling:
+
+```sql
+SET datafusion.sql_parser.enable_ident_normalization = false;
+CREATE TABLE paimon.my_db.preserved_example (ID INT);  -- stores `ID`
+```
+
+Changing the option does not rename existing schema fields. Quote an existing
+mixed-case or uppercase field when normalization is enabled.
+
 Unsupported syntax (will return an error):
 - `CREATE EXTERNAL TABLE`
 - `LOCATION`

Reply via email to