Repository: dotnet/efcore (2025-08-27T01:09:36Z)

Issue:
Model change detected because of newlines across Windows/Unix
SQL strings in various parts of the model (e.g. default value SQL, filters...) may embed newlines, and if they do, falsely trigger a model change (and therefore a PendingModelChangesWarning). We should consider normalizing newlines for the purpose of model diffing to prevent this.

Reported by @dcby in https://github.com/dotnet/efcore/issues/35285#issuecomment-2559892621


Discussion:
We had the exacte same issue, in a multi line constraint. 
was really hard to find what this was. as our dev machines are all windows. the build machine is Linux. and the database updater job was failing, with the error message not good enough to know what was going on. 
'Microsoft.EntityFrameworkCore.Migrations.PendingModelChangesWarning' exception.

`An error was generated for warning 'Microsoft.EntityFrameworkCore.Migrations.PendingModelChangesWarning': The model for context 'DbContext' has pending changes. Add a new migration before updating the database. This exception can be suppressed or logged by passing event ID 'RelationalEventId.PendingModelChangesWarning' to the 'ConfigureWarnings' method in 'DbContext.OnConfiguring' or 'AddDbContext'.`

Took us quite some time to find it was related to a multiline constraint

Hoping this will be fixed any time soon.
Agree; this is very frustrating.  It cannot be caught in development since the code was written there and so the line endings match.  It doesn't get caught in our linux CI tests because the database doesn't exist there; the database is created for tests not migrated.  It wouldn't get caught in Windows CI tests because the line endings would match.  So it only gets caught when pushing to production.
Got the same, when were pushing to linux dev server. Migration was made on windows, and there was pending changes also on mac.

Just transformed all content into a regular string from multi-line one.



Patch:
diff --git a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs
index 26305af61aa..555ff93715b 100644
--- a/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs
+++ b/src/EFCore.Relational/Migrations/Internal/MigrationsModelDiffer.cs
@@ -595,7 +595,7 @@ protected virtual IEnumerable<MigrationOperation> Diff(
         var sourceMigrationsAnnotations = source.GetAnnotations();
         var targetMigrationsAnnotations = target.GetAnnotations();
 
-        if (source.Comment != target.Comment
+        if (!MultilineEquals(source.Comment, target.Comment)
             || HasDifferences(sourceMigrationsAnnotations, targetMigrationsAnnotations))
         {
             var alterTableOperation = new AlterTableOperation
@@ -986,11 +986,11 @@ private static bool ColumnStructureEquals(IColumn source, IColumn target)
             && source.MaxLength == target.MaxLength
             && source.IsFixedLength == target.IsFixedLength
             && source.Collation == target.Collation
-            && source.Comment == target.Comment
+            && MultilineEquals(source.Comment, target.Comment)
             && source.IsStored == target.IsStored
-            && source.ComputedColumnSql == target.ComputedColumnSql
+            && MultilineEquals(source.ComputedColumnSql, target.ComputedColumnSql)
             && Equals(sourceDefault, targetDefault)
-            && source.DefaultValueSql == target.DefaultValueSql;
+            && MultilineEquals(source.DefaultValueSql, target.DefaultValueSql);
     }
 
     private static bool EntityTypePathEquals(ITypeBase source, ITypeBase target, DiffContext diffContext)
@@ -1079,12 +1079,12 @@ protected virtual IEnumerable<MigrationOperation> Diff(
 
         if (isNullableChanged
             || columnTypeChanged
-            || source.DefaultValueSql != target.DefaultValueSql
-            || source.ComputedColumnSql != target.ComputedColumnSql
+            || !MultilineEquals(source.DefaultValueSql, target.DefaultValueSql)
+            || !MultilineEquals(source.ComputedColumnSql, target.ComputedColumnSql)
             || source.IsStored != target.IsStored
             || sourceDefault?.GetType() != targetDefault?.GetType()
             || (sourceDefault != DBNull.Value && !target.ProviderValueComparer.Equals(sourceDefault, targetDefault))
-            || source.Comment != target.Comment
+            || !MultilineEquals(source.Comment, target.Comment)
             || source.Collation != target.Collation
             || source.Order != target.Order
             || HasDifferences(sourceMigrationsAnnotations, targetMigrationsAnnotations))
@@ -1507,7 +1507,7 @@ private bool IndexStructureEquals(ITableIndex source, ITableIndex target, DiffCo
                 || (source.IsDescending is not null
                     && target.IsDescending is not null
                     && source.IsDescending.SequenceEqual(target.IsDescending)))
-            && source.Filter == target.Filter
+            && MultilineEquals(source.Filter, target.Filter)
             && !HasDifferences(source.GetAnnotations(), target.GetAnnotations())
             && source.Columns.Select(p => p.Name).SequenceEqual(
                 target.Columns.Select(p => diffContext.FindSource(p)?.Name));
@@ -1600,7 +1600,7 @@ protected virtual IEnumerable<MigrationOperation> Diff(
             Remove,
             (s, t, c) => c.FindTable(s.EntityType) == c.FindSource(c.FindTable(t.EntityType))
                 && string.Equals(s.Name, t.Name, StringComparison.OrdinalIgnoreCase)
-                && string.Equals(s.Sql, t.Sql, StringComparison.OrdinalIgnoreCase));
+                && MultilineEquals(s.Sql, t.Sql));
 
     /// <summary>
     ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
@@ -2481,7 +2481,7 @@ protected virtual bool HasDifferences(IEnumerable<IAnnotation> source, IEnumerab
         foreach (var annotation in source)
         {
             var index = unmatched.FindIndex(a
-                => a.Name == annotation.Name && StructuralComparisons.StructuralEqualityComparer.Equals(a.Value, annotation.Value));
+                => a.Name == annotation.Name && AnnotationValuesEqual(a, annotation));
             if (index == -1)
             {
                 return true;
@@ -2491,8 +2491,19 @@ protected virtual bool HasDifferences(IEnumerable<IAnnotation> source, IEnumerab
         }
 
         return unmatched.Count != 0;
+
+        static bool AnnotationValuesEqual(IAnnotation left, IAnnotation right)
+            => left.Value is string leftString && right.Value is string rightString
+                ? MultilineEquals(leftString, rightString)
+                : StructuralComparisons.StructuralEqualityComparer.Equals(left.Value, right.Value);
     }
 
+    private static bool MultilineEquals(string? sourceString, string? targetString, StringComparison comparisonType = StringComparison.Ordinal)
+        => ReferenceEquals(sourceString, targetString)
+            || (sourceString is not null
+                && targetString is not null
+                && string.Equals(sourceString.ReplaceLineEndings(), targetString.ReplaceLineEndings(), comparisonType));
+
     /// <summary>
     ///     This is an internal API that supports the Entity Framework Core infrastructure and not subject to
     ///     the same compatibility standards as public APIs. It may be changed or removed without notice in
diff --git a/src/EFCore/ChangeTracking/PropertyValues.cs b/src/EFCore/ChangeTracking/PropertyValues.cs
index 79a53634f9c..95777844184 100644
--- a/src/EFCore/ChangeTracking/PropertyValues.cs
+++ b/src/EFCore/ChangeTracking/PropertyValues.cs
@@ -226,7 +226,7 @@ internal virtual IComplexProperty CheckCollection(IComplexProperty complexProper
     /// <typeparam name="TValue">The type of the property.</typeparam>
     /// <param name="propertyName">The property name.</param>
     /// <param name="value">The property value if any.</param>
-    /// <returns>True if the property exists, otherwise false.</returns>
+    /// <returns><see langword="true" /> if the property exists, otherwise <see langword="false" />.</returns>
     public virtual bool TryGetValue<TValue>(string propertyName, out TValue value)
     {
         var property = Properties.FirstOrDefault(p => p.Name == propertyName);


Test patch:
diff --git a/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs b/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs
index 5c7f2f3310f..4553967aeb3 100644
--- a/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs
+++ b/test/EFCore.Relational.Tests/Migrations/Internal/MigrationsModelDifferTest.cs
@@ -12318,6 +12318,127 @@ public void Model_differ_does_not_detect_entity_type_mapped_to_TVF()
             result => Assert.Equal(0, result.Count),
             skipSourceConventions: true);
 
+    [ConditionalFact]
+    public void Model_differ_ignores_newline_differences_in_DefaultValueSql()
+        => Execute(
+            source => source.Entity("Cat", x => x.Property<string>("Meow").HasDefaultValueSql("SELECT\r\n'test'")),
+            target => target.Entity("Cat", x => x.Property<string>("Meow").HasDefaultValueSql("SELECT\n'test'")),
+            result => Assert.Empty(result));
+
+    [ConditionalFact]
+    public void Model_differ_ignores_newline_differences_in_ComputedColumnSql()
+        => Execute(
+            source => source.Entity("Cat", x => x.Property<string>("Meow").HasComputedColumnSql("UPPER(\r\nName)")),
+            target => target.Entity("Cat", x => x.Property<string>("Meow").HasComputedColumnSql("UPPER(\nName)")),
+            result => Assert.Empty(result));
+
+    [ConditionalFact]
+    public void Model_differ_ignores_newline_differences_in_index_filter()
+        => Execute(
+            source => source.Entity("Cat", x =>
+            {
+                x.Property<string>("Name");
+                x.HasIndex("Name").HasFilter("Name IS NOT\r\nNULL");
+            }),
+            target => target.Entity("Cat", x =>
+            {
+                x.Property<string>("Name");
+                x.HasIndex("Name").HasFilter("Name IS NOT\nNULL");
+            }),
+            result => Assert.Empty(result));
+
+    [ConditionalFact]
+    public void Model_differ_ignores_newline_differences_in_check_constraint()
+        => Execute(
+            source => source.Entity("Cat", x =>
+            {
+                x.Property<int>("Age");
+                x.ToTable(t => t.HasCheckConstraint("CK_Cat_Age", "Age >\r\n0"));
+            }),
+            target => target.Entity("Cat", x =>
+            {
+                x.Property<int>("Age");
+                x.ToTable(t => t.HasCheckConstraint("CK_Cat_Age", "Age >\n0"));
+            }),
+            result => Assert.Empty(result));
+
+    [ConditionalFact]
+    public void Model_differ_ignores_carriage_return_differences_in_DefaultValueSql()
+        => Execute(
+            source => source.Entity("Cat", x => x.Property<string>("Meow").HasDefaultValueSql("SELECT\r'test'")),
+            target => target.Entity("Cat", x => x.Property<string>("Meow").HasDefaultValueSql("SELECT\n'test'")),
+            result => Assert.Empty(result));
+
+    [ConditionalFact]
+    public void Model_differ_ignores_complex_newline_differences_in_ComputedColumnSql()
+        => Execute(
+            source => source.Entity("Cat", x => x.Property<string>("Meow").HasComputedColumnSql("CASE\r\nWHEN Age > 5\r\nTHEN 'Old'\r\nELSE 'Young'\r\nEND")),
+            target => target.Entity("Cat", x => x.Property<string>("Meow").HasComputedColumnSql("CASE\nWHEN Age > 5\nTHEN 'Old'\nELSE 'Young'\nEND")),
+            result => Assert.Empty(result));
+
+    [ConditionalFact]
+    public void Model_differ_ignores_newline_differences_in_table_comments()
+        => Execute(
+            source => source.Entity("Cat", x => x.ToTable(t => t.HasComment("Table for storing\r\ncat information"))),
+            target => target.Entity("Cat", x => x.ToTable(t => t.HasComment("Table for storing\ncat information"))),
+            result => Assert.Empty(result));
+
+    [ConditionalFact]
+    public void Model_differ_ignores_newline_differences_in_column_comments()
+        => Execute(
+            source => source.Entity("Cat", x => x.Property<string>("Name").HasComment("Cat name\r\nfield")),
+            target => target.Entity("Cat", x => x.Property<string>("Name").HasComment("Cat name\nfield")),
+            result => Assert.Empty(result));
+
+    [ConditionalFact]
+    public void Model_differ_ignores_newline_differences_in_DefaultValueSql_annotations()
+        => Execute(
+            source => source.Entity("Cat", x => x.Property<string>("Name").HasAnnotation("Relational:DefaultValueSql", "SELECT\r\n'test'")),
+            target => target.Entity("Cat", x => x.Property<string>("Name").HasAnnotation("Relational:DefaultValueSql", "SELECT\n'test'")),
+            result => Assert.Empty(result));
+
+    [ConditionalFact]
+    public void Model_differ_ignores_newline_differences_in_ComputedColumnSql_annotations()
+        => Execute(
+            source => source.Entity("Cat", x => x.Property<string>("Name").HasAnnotation("Relational:ComputedColumnSql", "UPPER(\r\nName)")),
+            target => target.Entity("Cat", x => x.Property<string>("Name").HasAnnotation("Relational:ComputedColumnSql", "UPPER(\nName)")),
+            result => Assert.Empty(result));
+
+    [ConditionalFact]
+    public void Model_differ_ignores_newline_differences_in_Comment_annotations()
+        => Execute(
+            source => source.Entity("Cat", x => x.Property<string>("Name").HasAnnotation("Relational:Comment", "Multi-line\r\ncomment")),
+            target => target.Entity("Cat", x => x.Property<string>("Name").HasAnnotation("Relational:Comment", "Multi-line\ncomment")),
+            result => Assert.Empty(result));
+
+    [ConditionalFact]
+    public void Model_differ_ignores_newline_differences_in_ViewDefinitionSql_annotations()
+        => Execute(
+            source => source.Entity("Cat", x => x.HasAnnotation("Relational:ViewDefinitionSql", "SELECT Id,\r\nName FROM Cats")),
+            target => target.Entity("Cat", x => x.HasAnnotation("Relational:ViewDefinitionSql", "SELECT Id,\nName FROM Cats")),
+            result => Assert.Empty(result));
+
+    [ConditionalFact]
+    public void Model_differ_ignores_newline_differences_in_Filter_annotations()
+        => Execute(
+            source => source.Entity("Cat", x => x.Property<string>("Name").HasAnnotation("Relational:Filter", "Name IS NOT\r\nNULL")),
+            target => target.Entity("Cat", x => x.Property<string>("Name").HasAnnotation("Relational:Filter", "Name IS NOT\nNULL")),
+            result => Assert.Empty(result));
+
+    [ConditionalFact]
+    public void Model_differ_detects_actual_annotation_sql_changes()
+        => Execute(
+            source => source.Entity("Cat", x => x.Property<string>("Name").HasAnnotation("Relational:DefaultValueSql", "SELECT 'old'")),
+            target => target.Entity("Cat", x => x.Property<string>("Name").HasAnnotation("Relational:DefaultValueSql", "SELECT 'new'")),
+            result => Assert.Single(result));
+
+    [ConditionalFact]
+    public void Model_differ_ignores_newline_differences_in_non_relational_annotations()
+        => Execute(
+            source => source.Entity("Cat", x => x.Property<string>("Name").HasAnnotation("CustomAnnotation", "Value with\r\nnewlines")),
+            target => target.Entity("Cat", x => x.Property<string>("Name").HasAnnotation("CustomAnnotation", "Value with\nnewlines")),
+            result => Assert.Empty(result));
+
     protected override TestHelpers TestHelpers
         => FakeRelationalTestHelpers.Instance;
 }
diff --git a/test/EFCore.Specification.Tests/TestUtilities/ListLoggerFactory.cs b/test/EFCore.Specification.Tests/TestUtilities/ListLoggerFactory.cs
index f4acf95c785..ac0e443c97d 100644
--- a/test/EFCore.Specification.Tests/TestUtilities/ListLoggerFactory.cs
+++ b/test/EFCore.Specification.Tests/TestUtilities/ListLoggerFactory.cs
@@ -57,7 +57,7 @@ public void Dispose()
         => _disposed = true;
 
     public static string NormalizeLineEndings(string expectedString)
-        => expectedString.Replace("\r", string.Empty).Replace("\n", Environment.NewLine);
+        => expectedString.ReplaceLineEndings();
 
     protected class ListLogger : ILogger
     {