@@ -402,38 +402,52 @@ impl MetadataWriter for SqliteMetadataWriter {
402402 . map ( |row| {
403403 let name: String = row. try_get ( 0 ) . unwrap_or_default ( ) ;
404404 let col_type: String = row. try_get ( 1 ) . unwrap_or_default ( ) ;
405- let nullable: bool = row. try_get :: < Option < bool > , _ > ( 2 ) . ok ( ) . flatten ( ) . unwrap_or ( true ) ;
405+ let nullable: bool = row
406+ . try_get :: < Option < bool > , _ > ( 2 )
407+ . ok ( )
408+ . flatten ( )
409+ . unwrap_or ( true ) ;
406410 ( name, col_type, nullable)
407411 } )
408412 . collect ( ) ;
409413
410- // For append mode (replace=false), validate schema compatibility
414+ // For append mode (replace=false), validate schema compatibility with evolution rules:
415+ // - Allowed: add nullable columns, remove columns, reorder columns
416+ // - Disallowed: add non-nullable columns, type changes for existing columns
411417 if !replace && !existing_columns. is_empty ( ) {
412- if existing_columns. len ( ) != columns. len ( ) {
413- return Err ( crate :: error:: DuckLakeError :: InvalidConfig ( format ! (
414- "Schema mismatch on append: existing table has {} columns, but write has {} columns" ,
415- existing_columns. len( ) ,
416- columns. len( )
417- ) ) ) ;
418- }
419-
420- for ( i, ( ( existing_name, existing_type, _existing_nullable) , new_col) ) in
421- existing_columns. iter ( ) . zip ( columns. iter ( ) ) . enumerate ( )
422- {
423- if existing_name != & new_col. name {
424- return Err ( crate :: error:: DuckLakeError :: InvalidConfig ( format ! (
425- "Schema mismatch on append: column {} is '{}' in existing table but '{}' in write" ,
426- i, existing_name, new_col. name
427- ) ) ) ;
428- }
429- if existing_type != & new_col. ducklake_type {
430- return Err ( crate :: error:: DuckLakeError :: InvalidConfig ( format ! (
431- "Schema mismatch on append: column '{}' has type '{}' in existing table but '{}' in write" ,
432- existing_name, existing_type, new_col. ducklake_type
433- ) ) ) ;
418+ use std:: collections:: HashMap ;
419+
420+ // Build map of existing columns: name -> (type, nullable)
421+ let existing_map: HashMap < & str , ( & str , bool ) > = existing_columns
422+ . iter ( )
423+ . map ( |( name, col_type, nullable) | {
424+ ( name. as_str ( ) , ( col_type. as_str ( ) , * nullable) )
425+ } )
426+ . collect ( ) ;
427+
428+ for new_col in columns. iter ( ) {
429+ if let Some ( ( existing_type, _existing_nullable) ) =
430+ existing_map. get ( new_col. name . as_str ( ) )
431+ {
432+ // Column exists - check type matches
433+ if * existing_type != new_col. ducklake_type {
434+ return Err ( crate :: error:: DuckLakeError :: InvalidConfig ( format ! (
435+ "Schema evolution error: column '{}' has type '{}' in existing table but '{}' in new schema. Type changes are not allowed." ,
436+ new_col. name, existing_type, new_col. ducklake_type
437+ ) ) ) ;
438+ }
439+ // Note: We allow nullable changes (strict -> nullable is safe for reads)
440+ } else {
441+ // New column - must be nullable
442+ if !new_col. is_nullable {
443+ return Err ( crate :: error:: DuckLakeError :: InvalidConfig ( format ! (
444+ "Schema evolution error: new column '{}' must be nullable. Adding non-nullable columns is not allowed." ,
445+ new_col. name
446+ ) ) ) ;
447+ }
434448 }
435- // Note: We allow nullable changes (strict -> nullable is safe, nullable -> strict requires validation)
436449 }
450+ // Columns in existing but not in new schema are implicitly removed - this is allowed
437451 }
438452
439453 sqlx:: query (
0 commit comments