Mert Özen Further With Every Line
Database Entity Framework Core — #24

Legacy Projects: Moving from EF Core 3.1 and EF Core 5 to EF Core 6

Mert Özen Nov 5, 2026 11 min 3 views
Legacy Projects: Moving from EF Core 3.1 and EF Core 5 to EF Core 6

The end of client evaluation in EF Core 3.0, FromSql split into FromSqlRaw/FromSqlInterpolated, many-to-many, filtered include and split queries in EF Core 5, how Nullable affects migrations when moving to EF Core 6, and support status.

Version note: This post covers what changed in EF Core 3.1 and EF Core 5; the code targets EF Core 3.1 and EF Core 5, and the migration section targets EF Core 6.

Guess First

We built every core post on EF Core 6. But the repo you get in your first week at a new job probably won't look like that. The csproj will say Microsoft.EntityFrameworkCore.SqlServer 3.1.x or 5.0.x, and the code will contain queries written years ago with a "it works, don't touch it" label on them. That's why the version posts start by going back in time.

Look at this query from the EF Core 2.2 days. You upgraded the project to EF Core 3.1 and it compiled cleanly:

static bool IsClassic(Book book) => book.PublishedYear < 1950;

var classics = db.Books
    .Where(b => IsClassic(b))
    .ToList();

What happens when the app runs? An empty list, the right result, or something else? And one more question: what did EF Core 2.2 send to the database for this query? The answers are in the first section, but write your guess down now.

EF Core 3.0: The End of Client Evaluation

EF Core 2.x quietly gave up when it met an expression it couldn't translate to SQL. IsClassic is a C# method we wrote, and the database has never heard of it. In that case 2.x pulled the whole Books table without a WHERE and applied the filter in memory. A warning went to the log, and that was it. Nobody noticed with a 20-row table on a dev machine; in production, with two million rows, the server's memory blew up.

EF Core 3.0 rewrote the LINQ pipeline from scratch and removed that behavior. Running the same code on EF Core 3.1.32 gave me this exception:

InvalidOperationException: The LINQ expression 'DbSet<Book>
    .Where(b => Program.IsClassic(b))' could not be translated. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to either AsEnumerable(), AsAsyncEnumerable(), ToList(), or ToListAsync(). See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.

So if you guessed "an empty list", you were wrong: the query doesn't run at all. And this isn't a compile error, it's a runtime error. If you have no tests, your users will find it. The message offers two ways out:

// 1. Rewrite the query so it can be translated to SQL (the right fix)
var classics = db.Books
    .Where(b => b.PublishedYear < 1950)
    .ToList();

// 2. Switch to the client on purpose: the whole table comes into memory
var classicsInMemory = db.Books
    .AsEnumerable()
    .Where(IsClassic)
    .ToList();

// Calling a C# method in the top-level Select is still allowed
static string Label(Book book) => book.PublishedYear < 1950 ? "classic" : "modern";

var labels = db.Books
    .Select(b => new { b.Title, Label = Label(b) })
    .ToList();

The second option is exactly what 2.x did, with one difference: now you can see it in the code. In a code review, the AsEnumerable() line shouts "we're pulling the whole table here". The third example is the rule we saw in the core posts: client evaluation is only allowed in the final Select, because by then the data has already arrived and you can't change the row count.

This is the real work when moving an old project to 3.x. Changing the package version takes five minutes; finding which queries were secretly running on the client in 2.x can take hours. The shortest path is to run tests that actually execute every query path right after the upgrade, and to search old logs for client evaluation warnings and start with those queries.

Why FromSql Was Split in Two

The second big change in 3.0 is on the raw SQL side. In 2.x, FromSql accepted both a plain string and an interpolated string, and which overload got picked was not always as obvious as it looked. The wrong overload could embed a value that should have been a parameter straight into the SQL. I tried to compile the old call on 3.1:

Program.cs(73,19): error CS0619: 'RelationalQueryableExtensions.FromSql<TEntity>(IQueryable<TEntity>, FormattableString)' is obsolete: 'For returning objects from SQL queries using plain strings, use FromSqlRaw instead. For returning objects from SQL queries using interpolated string syntax to create parameters, use FromSqlInterpolated instead. Call either new method directly on the DbSet at the root of the query.'
Program.cs(74,9): warning CS0618: 'RelationalDatabaseFacadeExtensions.ExecuteSqlCommand(DatabaseFacade, RawSqlString, params object[])' is obsolete: 'For the execution of SQL queries using plain strings, use ExecuteSqlRaw instead. For the execution of SQL queries using interpolated string syntax to create parameters, use ExecuteSqlInterpolated instead.'

Notice that FromSql is a compile error while ExecuteSqlCommand is only a warning. So a green build doesn't mean the raw SQL side is clean. The new names state the intent:

var minYear = 1950;

// Interpolated: {minYear} automatically becomes a parameter
var a = db.Books
    .FromSqlInterpolated($"SELECT * FROM Books WHERE PublishedYear >= {minYear}")
    .ToList();

// Raw: you pass the parameters separately as {0}, {1}
var b = db.Books
    .FromSqlRaw("SELECT * FROM Books WHERE PublishedYear >= {0}", minYear)
    .ToList();

Both produce the same parameterized SQL. Looking at the first one with ToQueryString(), which came in EF Core 5, the output was:

.param set p0 1950

SELECT * FROM Books WHERE PublishedYear >= @p0

We already used these two methods in the core post on raw SQL; this section explains where they came from. One more note: the FromSql name came back in EF Core 7, but this time only in its safe form that takes an interpolated string. We'll see it in the next post.

The Quiet Changes in 3.x

A few more items don't throw but do change behavior. Knowing them answers "why is this line here?" very quickly when you read old code.

Include now produces a single query. 2.x sent a separate SQL statement per collection; 3.0 folded them all into one command with JOINs. Include two collections and the row count multiplies, so the cartesian explosion risk arrived with 3.0. Its fix comes in 5.0.

Query types were removed. In 2.x you used DbQuery<T> and modelBuilder.Query<T>() for views or keyless results. Since 3.0 these are keyless entities: a normal DbSet<T> plus HasNoKey().

dotnet ef left the SDK. In 2.x it shipped with the SDK; since 3.0 it's a separate .NET tool that has to be installed at the same version as the packages. If a new teammate's machine says the dotnet ef command can't be found, this is why.

Cascade delete now happens immediately. When you Remove a principal, its dependents move to the Deleted state without waiting for SaveChanges. And 3.1 is the last EF Core version that runs on .NET Framework. 5.0 targets netstandard2.1, so .NET Framework projects have to stay on 3.1.

EF Core 5: Many-to-Many Without a Join Entity

Say a book has several tags and a tag has several books. On 3.1, when I put a collection on both sides and left it at that, EnsureCreated said:

InvalidOperationException: Unable to determine the relationship represented by navigation property 'Book.Tags' of type 'List<Tag>'. Either manually configure the relationship, or ignore this property using the '[NotMapped]' attribute or by using 'EntityTypeBuilder.Ignore' in 'OnModelCreating'.

On 3.1, many-to-many meant writing the join table as an entity, giving it a composite key with the Fluent API and wiring both sides to it. Most of the BookTag or BookTags classes you see in old projects are these mandatory join entities that carry no extra columns at all. With EF Core 5 the same model just works:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Description { get; set; }
    public int PublishedYear { get; set; }
    public decimal Price { get; set; }
    public int AuthorId { get; set; }
    public Author Author { get; set; }
    public List<Tag> Tags { get; set; } = new List<Tag>();
}

public class Tag
{
    public int Id { get; set; }
    public string Name { get; set; }
    public List<Book> Books { get; set; } = new List<Book>();
}

In the output of GenerateCreateScript() on EF Core 5.0.17, the join table is created by convention:

CREATE TABLE "BookTag" (
    "BooksId" INTEGER NOT NULL,
    "TagsId" INTEGER NOT NULL,
    CONSTRAINT "PK_BookTag" PRIMARY KEY ("BooksId", "TagsId"),
    CONSTRAINT "FK_BookTag_Books_BooksId" FOREIGN KEY ("BooksId") REFERENCES "Books" ("Id") ON DELETE CASCADE,
    CONSTRAINT "FK_BookTag_Tags_TagsId" FOREIGN KEY ("TagsId") REFERENCES "Tags" ("Id") ON DELETE CASCADE
);

This is the skip navigation we saw in the core posts. You never mention the join table in a query; EF joins it for you. When moving an old project to 5, you don't have to delete existing join entities, and if the join table carries extra columns you shouldn't. But if the table and column names don't match the convention, deleting the entity and switching to skip navigations produces a migration. Read that migration carefully.

EF Core 5: Filtered Include, Split Queries and ToQueryString

The three 5.0 features that touch day-to-day work the most come together in one query:

protected override void OnConfiguring(DbContextOptionsBuilder options)
{
    options.UseSqlite("Data Source=bookstore5.db");
    options.LogTo(Console.WriteLine,
        new[] { DbLoggerCategory.Database.Command.Name },
        LogLevel.Information);
}

var authors = db.Authors
    .Include(a => a.Books.Where(b => b.PublishedYear >= 2000))
    .AsSplitQuery()
    .ToList();

On 3.1 you couldn't write a Where inside Include; you either loaded the whole collection or filled it by hand with a separate query. AsSplitQuery() is the answer to the cartesian explosion risk that 3.0 introduced: it splits the query into pieces like 2.x did, but only when you ask. LogTo replaces the ILoggerFactory setup of 3.1 with a single line. These are the two commands that hit the log on EF Core 5:

SELECT "a"."Id", "a"."Name"
FROM "Authors" AS "a"
ORDER BY "a"."Id"

SELECT "t"."Id", "t"."AuthorId", "t"."Description", "t"."Price", "t"."PublishedYear", "t"."Title", "a"."Id"
FROM "Authors" AS "a"
INNER JOIN (
    SELECT "b"."Id", "b"."AuthorId", "b"."Description", "b"."Price", "b"."PublishedYear", "b"."Title"
    FROM "Books" AS "b"
    WHERE "b"."PublishedYear" >= 2000
) AS "t" ON "a"."Id" = "t"."AuthorId"
ORDER BY "a"."Id"

The filter sits inside the second query, so only books from 2000 onward come back. ToQueryString() and ChangeTracker.DebugView also arrived in 5.0; you no longer have to execute a query to see its SQL. Add TPT to the list too: in 5.0 it's enough to give each type in the hierarchy its own table with ToTable. The explicit UseTptMappingStrategy() call came in 7.0.

Moving to EF Core 6 and Nullable

The package side of moving a 3.1 or 5 project to 6 is short:

<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>

<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.36" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.36" />
dotnet new tool-manifest
dotnet tool install dotnet-ef --version 6.0.36

The real surprise is in the second line. .NET 6 templates come with <Nullable>enable</Nullable>, and it's tempting to turn it on during the upgrade. EF Core has looked at nullable reference types since 3.0: with NRT on, string means NOT NULL and string? means NULL. Without touching the model, I only flipped that flag and ran dotnet ef migrations add EnableNullable. The tool first warned "An operation was scaffolded that may result in the loss of data", then generated this for three columns (I'm showing the first):

migrationBuilder.AlterColumn<string>(
    name: "Title",
    table: "Books",
    type: "TEXT",
    nullable: false,
    defaultValue: "",
    oldClrType: typeof(string),
    oldType: "TEXT",
    oldNullable: true);

The same exists for Books.Description and Authors.Name. For Title and Name that may be what you want, but Description really is optional. After applying the migration and saving a book without a description:

Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
 ---> Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 19: 'NOT NULL constraint failed: Books.Description'.

The fix is to ask "is this really required?" of every string the moment you turn NRT on:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = null!;
    public string? Description { get; set; }
    public int PublishedYear { get; set; }
    public int AuthorId { get; set; }
    public Author Author { get; set; } = null!;
}

public class BookstoreContext : DbContext
{
    public DbSet<Author> Authors => Set<Author>();
    public DbSet<Book> Books => Set<Book>();
}

In the migration I regenerated with this model, only Title and Name remained, and Description stayed nullable. = null! silences the compiler's CS8618 warning; it's harmless because EF fills the value. Writing the DbSets with Set<T>() is the way the EF docs recommend to avoid the same warning.

As for support: EF Core 3.1 was LTS and went out of support on December 13, 2022; EF Core 5 was STS and ended on May 10, 2022. EF Core 6 ended on November 12, 2024 as well. So 6 isn't a destination, it's the first stop. But it's the right stop: the core API lives here, and every later version builds on top of it.

Common Pitfalls

First, treating a green build as a finished migration. Client evaluation errors and NOT NULL errors both show up at runtime. If you don't have integration tests that actually run your queries, writing them is the first job before upgrading.

Second, not reading migrations when you jump versions. Turning a join entity into skip navigations on 5, or enabling NRT on 6, looks like a one-line change to you, but it produces AlterColumn calls and table rebuilds that will run against the database. On SQLite these operations are done by rebuilding the table from scratch.

Third, not matching the dotnet ef version to the packages. An old globally installed tool sometimes works with new packages and sometimes gives nonsensical errors. Install it inside the project with a tool manifest and keep its version equal to the packages.

Fourth, the native library of old packages on Apple Silicon. SQLitePCLRaw 2.0.4, which EF Core 5.0.17 pulls in, couldn't find the e_sqlite3 library on an M-series Mac and threw DllNotFoundException. Adding SQLitePCLRaw.bundle_e_sqlite3 2.1.2 to the project explicitly fixed it. 3.1.32 already comes with 2.1.2.

Fifth, a behavior change on the SQLite side of EF Core 6: Microsoft.Data.Sqlite 6.0 pools connections. Even after you close the connection the file can stay open; if your tests get a "file in use" error when deleting the database file, especially on Windows, call SqliteConnection.ClearAllPools().

A Small Experiment

★☆☆ Install EF Core 3.1.32 into an empty console project targeting net6.0 and run the IsClassic query from the start of this post. Upgrade the same project to 5.0.17, then to 6.0.36, and watch how the exception message changes.

★★☆ Build a many-to-many with a BookTag join entity on 3.1 and generate its migration. Then move the project to 5, delete the join entity, put skip navigations on both sides and add a new migration. Is the migration empty, or does it rename the table?

★★★ Build a model with three string properties in a project with nullable disabled and take the first migration. Then enable NRT, decide which property is really optional, and get both required and optional columns right with a single migration. If a row already contains a NULL value, observe what defaultValue: "" does.

In this post we brought old projects up to EF Core 6. In the core posts we said "we'll see this in the version posts" in a few places: bulk updates, JSON columns, TPC. In the next post we move to EF Core 7, where all of them arrived.