<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:a10="http://www.w3.org/2005/Atom" version="2.0">
  <channel xmlns:media="http://search.yahoo.com/mrss/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <title>ABP.IO Stories</title>
    <link>https://abp.io/community/articles</link>
    <description>A hub for ABP Framework, .NET, and software development. Access articles, tutorials, news, and contribute to the ABP community.</description>
    <lastBuildDate>Wed, 30 Sep 2026 16:15:29 Z</lastBuildDate>
    <generator>Community - ABP.IO</generator>
    <image>
      <url>https://abp.io/assets/favicon.ico/favicon-32x32.png</url>
      <title>ABP.IO Stories</title>
      <link>https://abp.io/community/articles</link>
    </image>
    <a10:link rel="self" type="application/rss+xml" title="self" href="https://abp.io/community/rss?member=gizemfirat" />
    <item>
      <guid isPermaLink="true">https://abp.io/community/posts/ef-core-11-query-improvements-groupby-fulljoin-maxby-and-minby-ldyi3n03</guid>
      <link>https://abp.io/community/posts/ef-core-11-query-improvements-groupby-fulljoin-maxby-and-minby-ldyi3n03</link>
      <a10:author>
        <a10:name>gizemfirat</a10:name>
        <a10:uri>https://abp.io/community/members/gizemfirat</a10:uri>
      </a10:author>
      <category>sql</category>
      <category>EfCore</category>
      <category>entity-framework-core</category>
      <category>testing</category>
      <category>new features</category>
      <title>EF Core 11 Query Improvements: GroupBy, FullJoin, MaxBy, and MinBy</title>
      <description>A hands-on look at EF Core 11's LINQ translation improvements (FullJoin, GroupBy with MaxBy, MinBy/MaxBy and leaner split queries), tested against a real SQL Server database with a music "year in review" report. Each feature comes with the generated SQL, and a test proves the GroupBy + MaxBy query runs in a single round-trip.</description>
      <pubDate>Mon, 28 Sep 2026 07:04:29 Z</pubDate>
      <a10:updated>2026-09-30T15:51:22Z</a10:updated>
      <content:encoded><![CDATA[<h1>EF Core 11 Query Improvements, Proven With a Music &quot;Wrapped&quot; Report</h1>
<p>Every December, music streaming apps ship the same ritual: a personalized, shareable recap of your year in listening. It's a deceptively simple feature to build a demo around and a surprisingly good one to stress-test a query engine with. A &quot;your year in review&quot; report is, under the hood, nothing but reporting SQL: comparisons across time periods, per-group winners, multi-key aggregations and joins across a handful of related tables. If a new version of your ORM promises to make exactly that kind of query easier or safer to write, the honest way to find out is to build the report and read the SQL it produces instead of taking the changelog's word for it.</p>
<p>That's the premise of this article. EF Core 11 (currently shipping as <code>11.0.0-rc.1</code>) ships a handful of LINQ-to-SQL translation improvements that sound small on paper but remove real friction from everyday reporting code: a native <code>FullJoin</code>, a smarter translation for <code>GroupBy</code> combined with a per-group <code>MaxBy</code>/<code>MinBy</code>, and a split-query optimization that stops a to-one navigation from leaking a join into collection statements it doesn't belong in. The official release notes describe <em>that</em> these exist. Some come with a short SQL sample; others, like the <code>GroupBy</code> enhancements, are covered by a paragraph and a list of GitHub PRs. None of them answer the one question every EF developer actually cares about: does this really collapse into one round-trip, or am I about to ship an N+1?</p>
<p>To answer that with evidence instead of trust, we built a small, deliberately &quot;boring&quot; business domain around a subject everyone already understands: an end-of-year music listening report in the style of the year-end listening recaps most streaming apps publish. We called it <strong>YearSound Wrapped</strong>. It's a plain ASP.NET Core Blazor Server app on a Clean Architecture layout (Domain / Application / Infrastructure / Web, no extra framework on top), backed by SQL Server, seeded with 42 artists, 286 tracks and roughly 15,000 play events across two years. Every query in this article runs against that real dataset, and every SQL snippet below is a real captured execution (<code>ToQueryString()</code> or logged <code>DbCommand</code> text) rather than a guess.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-27-ef-core-11-query-improvements/home.png" alt="YearSound Wrapped home screen" /></p>
<p>The domain is five tables. Every query below walks one of the paths in this diagram, so it's worth a quick look before reading the generated SQL:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-27-ef-core-11-query-improvements/data-model.png" alt="YearSound Wrapped data model: Listener, PlayEvent, Track, Artist and FavoriteTrack" /></p>
<h2>Environment note: this is preview software</h2>
<p>Before getting into the queries, a disclosure that matters if you're deciding whether to adopt this today. <code>Microsoft.EntityFrameworkCore.SqlServer 11.0.0-rc.1.26425.128</code> targets <strong><code>net11.0</code> only</strong>: there is no <code>net10.0</code> fallback in the package's dependency graph. .NET 11 itself hasn't reached GA at the time of writing (expected around November 2026), so the SDK isn't something most machines have installed yet. This one didn't either.</p>
<p>Rather than installing a preview SDK system-wide, the whole build/test loop for this project runs against the <code>mcr.microsoft.com/dotnet/sdk:11.0</code> Docker image, with SQL Server 2022 running in a sibling container via <code>docker-compose.yml</code>. This kept the host machine on stable .NET 10 for everything else while still giving us a real <code>net11.0</code> compiler and runtime to validate against. The one place this bit us: running Testcontainers-based xUnit tests <em>from inside</em> that build container is a Docker-in-Docker scenario, and on Docker Desktop for Windows the WSL2 network layer doesn't expose the SQL Server sibling container the way a native Linux Docker host would. The practical fix was to install the RC SDK to an isolated, non-<code>PATH</code> folder on the host (<code>dotnet-install.ps1 -InstallDir ... -NoPath</code>) and run <code>dotnet test</code> from there directly. At that point Testcontainers talks to Docker Desktop with no extra layer in between and the problem disappears entirely.</p>
<p>None of this is a criticism of EF Core 11 itself. It's the normal cost of working against release-candidate tooling, and it's worth budgeting time for if you want to try this before GA.</p>
<h2>Feature 1: <code>FullJoin</code> for year-over-year comparison</h2>
<p><code>FullJoin</code> also ships in .NET 11 as a plain LINQ operator over in-memory collections. This section is about the EF Core side of it: what the operator turns into when both sources are <code>IQueryable</code>s backed by SQL Server.</p>
<h3>The query</h3>
<p>The most natural &quot;full outer join&quot; question in a Wrapped-style report is: which artists survived from one year to the next, which ones did you drop and which ones did you discover? That's exactly a full outer join on artist identity between two filtered subsets of <code>PlayEvent</code>.</p>
<pre><code class="language-csharp">public async Task&lt;List&lt;ArtistYearComparisonDto&gt;&gt; GetYearComparisonAsync(CancellationToken ct = default)
{
    var artists2025 = db.PlayEvents
        .Where(p =&gt; p.PlayedAt.Year == 2025)
        .Select(p =&gt; p.Track.Artist)
        .Distinct();

    var artists2026 = db.PlayEvents
        .Where(p =&gt; p.PlayedAt.Year == 2026)
        .Select(p =&gt; p.Track.Artist)
        .Distinct();

    return await artists2025
        .FullJoin(
            artists2026,
            a =&gt; a.Id,
            a =&gt; a.Id,
            (a2025, a2026) =&gt; new ArtistYearComparisonDto
            {
                ArtistName = (a2025 ?? a2026)!.Name,
                ListenedIn2025 = a2025 != null,
                ListenedIn2026 = a2026 != null
            })
        .ToListAsync(ct);
}
</code></pre>
<h3>The generated SQL</h3>
<p>That LINQ compiles to a single, native <code>FULL JOIN</code> in T-SQL. This is the real captured output against SQL Server 2022:</p>
<pre><code class="language-sql">SELECT [s].[Id], [s].[Country], [s].[Genre], [s].[Name], [s0].[Id], [s0].[Country], [s0].[Genre], [s0].[Name]
FROM (
    SELECT DISTINCT [a].[Id], [a].[Country], [a].[Genre], [a].[Name]
    FROM [PlayEvents] AS [p]
    INNER JOIN [Tracks] AS [t] ON [p].[TrackId] = [t].[Id]
    INNER JOIN [Artists] AS [a] ON [t].[ArtistId] = [a].[Id]
    WHERE DATEPART(year, [p].[PlayedAt]) = 2025
) AS [s]
FULL JOIN (
    SELECT DISTINCT [a0].[Id], [a0].[Country], [a0].[Genre], [a0].[Name]
    FROM [PlayEvents] AS [p0]
    INNER JOIN [Tracks] AS [t0] ON [p0].[TrackId] = [t0].[Id]
    INNER JOIN [Artists] AS [a0] ON [t0].[ArtistId] = [a0].[Id]
    WHERE DATEPART(year, [p0].[PlayedAt]) = 2026
) AS [s0] ON [s].[Id] = [s0].[Id]
</code></pre>
<h3>What it replaces</h3>
<p><code>FullJoin</code> is new in EF Core 11, but it's worth placing it in context rather than jumping straight to the oldest possible comparison. EF Core 10 already added first-class <code>LeftJoin</code> and <code>RightJoin</code> LINQ operators, which replaced the old <code>GroupJoin().SelectMany().DefaultIfEmpty()</code> idiom for one-sided outer joins. So a fair EF Core 10-era &quot;before&quot; for a full outer join is <code>artists2025.LeftJoin(artists2026, ...).Concat(artists2025.RightJoin(artists2026, ...).Where(...))</code>, filtering the second half down to the unmatched rows so nothing is double-counted. The <code>GroupJoin</code> + <code>DefaultIfEmpty</code> + <code>Concat</code> version below is the older idiom that predates even EF Core 10's <code>LeftJoin</code>/<code>RightJoin</code>, shown because it's still the form most existing EF Core 9-and-earlier codebases actually have on disk today:</p>
<pre><code class="language-csharp">var left = from a2025 in artists2025
           join a2026 in artists2026 on a2025.Id equals a2026.Id into g
           from a2026 in g.DefaultIfEmpty()
           select new { a2025, a2026 };

var right = from a2026 in artists2026
            join a2025 in artists2025 on a2026.Id equals a2025.Id into g
            from a2025 in g.DefaultIfEmpty()
            where a2025 == null
            select new { a2025 = (Artist?)null, a2026 };

var comparison = await left.Concat(right).Select(...).ToListAsync();
</code></pre>
<p>Either way, <code>FullJoin</code> collapses this into one operator that says exactly what you mean, whether the pre-11 alternative in your codebase is the <code>LeftJoin</code>/<code>RightJoin</code>/<code>Concat</code> combination EF Core 10 made possible or the older <code>GroupJoin</code> gymnastics above.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-27-ef-core-11-query-improvements/year-comparison.png" alt="Year Comparison report, three artist carousels for Dropped / Loyal / Discovered" /></p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-27-ef-core-11-query-improvements/fulljoin-before-after.png" alt="FullJoin: EF Core 10 approach vs EF Core 11 approach, side by side" /></p>
<p>In the seeded dataset this returns 10 &quot;dropped&quot; artists, 22 &quot;stayed loyal&quot; and 10 &quot;newly discovered&quot;. The UI above renders each bucket as its own single-artist carousel, backed by this one query.</p>
<h2>Feature 2: <code>GroupBy</code> with a per-group <code>MaxBy</code>: proving it's one query</h2>
<h3>The query</h3>
<p>This is the feature the official docs are vaguest about, and the one this project spent the most effort verifying. The &quot;top track of each month&quot; report needs, for every month, the single play event with the highest <code>PlayDurationSeconds</code>:</p>
<pre><code class="language-csharp">// Compiles to a single SQL query in EF Core 11 (verified below).
public async Task&lt;List&lt;MonthlyTopTrackDto&gt;&gt; GetMonthlyTopTracksAsync(int year, CancellationToken ct = default)
{
    return await db.PlayEvents
        .Where(p =&gt; p.PlayedAt.Year == year)
        .GroupBy(p =&gt; p.PlayedAt.Month)
        .Select(g =&gt; new MonthlyTopTrackDto
        {
            Month = g.Key,
            TopTrack = g.MaxBy(p =&gt; p.PlayDurationSeconds)!.Track.Title,
            TotalMinutes = g.Sum(p =&gt; p.PlayDurationSeconds) / 60
        })
        .OrderBy(x =&gt; x.Month)
        .ToListAsync(ct);
}
</code></pre>
<h3>Proving it's a single round-trip</h3>
<p>The release notes for this improvement point at GitHub PRs and stop there. There's no worked example of the resulting SQL, and no explicit statement of whether &quot;translates to SQL&quot; also means &quot;translates to <em>one</em> SQL statement.&quot; It's tempting to say this would have fallen back to client evaluation or produced one query per group on EF Core 10, but that's not how EF Core actually behaves: since EF Core 3.0, a LINQ expression that can't be translated throws an <code>InvalidOperationException</code> at query time rather than silently degrading. Since per-group <code>MaxBy</code> translation is new in EF Core 11, the honest expectation for this exact shape on EF Core 10 is a translation exception, not a silent N+1. We did not verify this directly against an EF Core 10 build, so treat it as an informed expectation, not a measured result. What <em>is</em> measured, and worth closing with a test rather than an assumption, is whether EF Core 11 really executes this as a single round-trip:</p>
<pre><code class="language-csharp">[Fact]
public async Task GroupBy_with_MaxBy_should_translate_to_single_sql_query()
{
    var executedCommandCount = 0;
    var interceptor = new CountingCommandInterceptor(() =&gt; executedCommandCount++);

    var options = new DbContextOptionsBuilder&lt;AppDbContext&gt;()
        .UseSqlServer(fixture.Db.Database.GetConnectionString())
        .AddInterceptors(interceptor)
        .Options;

    await using var countedDb = new AppDbContext(options);
    var service = new WrappedReportService(countedDb);

    await service.GetMonthlyTopTracksAsync(2026);

    executedCommandCount.Should().Be(1);
}
</code></pre>
<p><code>CountingCommandInterceptor</code> is a <code>DbCommandInterceptor</code> that increments a counter every time SQL Server actually executes a command. Run against the real container, <strong>this test passes</strong>: <code>executedCommandCount == 1</code> for all twelve months, in a single round-trip. The captured SQL shows exactly how: <code>MaxBy</code> compiles into a correlated subquery embedded in the same <code>SELECT</code>, not a join and not a separate statement per group.</p>
<pre><code class="language-sql">SELECT [p0].[Key] AS [Month], (
    SELECT TOP(1) [t].[Title]
    FROM (
        SELECT [p2].[PlayDurationSeconds], [p2].[TrackId], DATEPART(month, [p2].[PlayedAt]) AS [Key]
        FROM [PlayEvents] AS [p2]
        WHERE DATEPART(year, [p2].[PlayedAt]) = 2026
    ) AS [p1]
    INNER JOIN [Tracks] AS [t] ON [p1].[TrackId] = [t].[Id]
    WHERE [p0].[Key] = [p1].[Key] OR ([p0].[Key] IS NULL AND [p1].[Key] IS NULL)
    ORDER BY [p1].[PlayDurationSeconds] DESC) AS [TopTrack],
    ISNULL(SUM([p0].[PlayDurationSeconds]), 0) / 60 AS [TotalMinutes]
FROM (
    SELECT [p].[PlayDurationSeconds], DATEPART(month, [p].[PlayedAt]) AS [Key]
    FROM [PlayEvents] AS [p]
    WHERE DATEPART(year, [p].[PlayedAt]) = 2026
) AS [p0]
GROUP BY [p0].[Key]
ORDER BY [p0].[Key]
</code></pre>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-27-ef-core-11-query-improvements/groupby-maxby-single-query.png" alt="GroupBy + MaxBy: the feared N+1 vs the measured single-query result" /></p>
<p>One nuance worth calling out explicitly, since it's the kind of thing &quot;translates to a single query&quot; can obscure: this is <em>one SQL statement</em>, not <em>one join</em>. EF Core 11 is choosing a correlated <code>TOP(1) ... ORDER BY</code> subquery per group key, evaluated inline. That's the right trade-off here (a join would have required deduplicating to one row per group afterward anyway), but it's the kind of detail you only get by reading the generated SQL rather than by reading &quot;compiles to SQL&quot; in a changelog.</p>
<h3>A gotcha while writing the interceptor test</h3>
<p>The first version of <code>CountingCommandInterceptor</code> only overrode the synchronous <code>ReaderExecuting</code> method. Since <code>GetMonthlyTopTracksAsync</code> calls <code>ToListAsync()</code>, EF Core invokes the <em>asynchronous</em> <code>ReaderExecutingAsync</code> hook instead, and the counter never incremented. That made the test <strong>fail</strong> with <code>executedCommandCount</code> stuck at <code>0</code> instead of the expected <code>1</code>, and the failure was easy to misread as &quot;EF Core is issuing zero queries&quot; or, worse, as evidence that the <code>GroupBy</code> + <code>MaxBy</code> translation itself was broken. The real cause had nothing to do with EF Core's query translation: the interceptor simply wasn't watching the code path this query actually took. The fix is to override both hooks:</p>
<pre><code class="language-csharp">private class CountingCommandInterceptor(Action onExecuting) : DbCommandInterceptor
{
    public override InterceptionResult&lt;DbDataReader&gt; ReaderExecuting(
        DbCommand command, CommandEventData eventData, InterceptionResult&lt;DbDataReader&gt; result)
    {
        onExecuting();
        return base.ReaderExecuting(command, eventData, result);
    }

    public override ValueTask&lt;InterceptionResult&lt;DbDataReader&gt;&gt; ReaderExecutingAsync(
        DbCommand command, CommandEventData eventData, InterceptionResult&lt;DbDataReader&gt; result,
        CancellationToken cancellationToken = default)
    {
        onExecuting();
        return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
    }
}
</code></pre>
<p>If you're writing your own command-counting interceptor for a codebase that mixes sync and async EF Core calls (most do), override both methods from the start. Otherwise a real translation problem and &quot;my interceptor is watching the wrong method&quot; look identical from the test output, and it's easy to spend time debugging the wrong layer.</p>
<h2>Feature 3: <code>GroupBy</code> for realistic multi-key reporting</h2>
<h3>The query</h3>
<p>Not every <code>GroupBy</code> question is about picking a winner per group. Sometimes it's plain aggregation for a report, grouped by more than one key. &quot;Which genre do you listen to and at what time of day?&quot; groups by a composite key and sums minutes:</p>
<pre><code class="language-csharp">public async Task&lt;List&lt;GenreTimeOfDayDto&gt;&gt; GetGenreByTimeOfDayAsync(int year, CancellationToken ct = default)
{
    return await db.PlayEvents
        .Where(p =&gt; p.PlayedAt.Year == year)
        .GroupBy(p =&gt; new { p.Track.Artist.Genre, TimeOfDay = p.PlayedAt.Hour / 6 })
        .Select(g =&gt; new GenreTimeOfDayDto
        {
            Genre = g.Key.Genre,
            TimeOfDay = g.Key.TimeOfDay,
            TotalMinutes = g.Sum(p =&gt; p.PlayDurationSeconds) / 60
        })
        .ToListAsync(ct);
}
</code></pre>
<h3>The generated SQL</h3>
<p>This one is unremarkable in the best way. It's a plain <code>GROUP BY</code> with two keys, no subquery tricks needed:</p>
<pre><code class="language-sql">SELECT [s].[Genre], [s].[TimeOfDay], ISNULL(SUM([s].[PlayDurationSeconds]), 0) / 60 AS [TotalMinutes]
FROM (
    SELECT [p].[PlayDurationSeconds], [a].[Genre], DATEPART(hour, [p].[PlayedAt]) / 6 AS [TimeOfDay]
    FROM [PlayEvents] AS [p]
    INNER JOIN [Tracks] AS [t] ON [p].[TrackId] = [t].[Id]
    INNER JOIN [Artists] AS [a] ON [t].[ArtistId] = [a].[Id]
    WHERE DATEPART(year, [p].[PlayedAt]) = 2026
) AS [s]
GROUP BY [s].[Genre], [s].[TimeOfDay]
</code></pre>
<p>It earns its place in this article because it's the shape most reporting dashboards actually need. It's also a good reminder that not every EF Core 11 improvement is about exotic edge cases. Some of the value is that ordinary multi-key <code>GroupBy</code> reporting keeps compiling cleanly to SQL as the projection gets more complex.</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-27-ef-core-11-query-improvements/genre-heatmap.png" alt="Genre x time-of-day heatmap, colored by total minutes listened" /></p>
<h2>Feature 4: <code>MaxBy</code>/<code>MinBy</code> outside of <code>GroupBy</code></h2>
<h3>The queries</h3>
<p>The two aggregate methods also work standalone, directly against an <code>IQueryable&lt;T&gt;</code>, which the official docs demonstrate with <code>MaxByAsync(b =&gt; b.Posts.Count())</code>. Here's the same shape applied to two different questions: the most-played artist overall and a &quot;hidden gem,&quot; a track that's in favorites but barely played.</p>
<pre><code class="language-csharp">public async Task&lt;string?&gt; GetTopArtistNameAsync(CancellationToken ct = default)
{
    var topArtist = await db.Artists
        .MaxByAsync(a =&gt; a.Tracks.SelectMany(t =&gt; t.Plays).Count(), ct);
    return topArtist?.Name;
}

public async Task&lt;string?&gt; GetHiddenGemTrackTitleAsync(CancellationToken ct = default)
{
    var hiddenGem = await db.FavoriteTracks
        .Select(f =&gt; f.Track)
        .MinByAsync(t =&gt; t.Plays.Count(), ct);
    return hiddenGem?.Title;
}
</code></pre>
<h3>The generated SQL</h3>
<p>Both compile to a <code>TOP(1) ... ORDER BY</code> query with the comparison key computed as a correlated subquery. No client evaluation is involved.</p>
<p>Top artist (<code>MaxByAsync</code>):</p>
<pre><code class="language-sql">DECLARE @p int = 1;
SELECT TOP(@p) [a].[Id], [a].[Country], [a].[Genre], [a].[Name]
FROM [Artists] AS [a]
ORDER BY (
    SELECT COUNT(*)
    FROM [Tracks] AS [t]
    INNER JOIN [PlayEvents] AS [p] ON [t].[Id] = [p].[TrackId]
    WHERE [a].[Id] = [t].[ArtistId]) DESC
</code></pre>
<p>Hidden gem (<code>MinByAsync</code>):</p>
<pre><code class="language-sql">DECLARE @p int = 1;

SELECT TOP(@p) [t].[Id], [t].[ArtistId], [t].[Duration], [t].[Title]
FROM [FavoriteTracks] AS [f]
INNER JOIN [Tracks] AS [t] ON [f].[TrackId] = [t].[Id]
ORDER BY (
    SELECT COUNT(*)
    FROM [PlayEvents] AS [p]
    WHERE [t].[Id] = [p].[TrackId])
</code></pre>
<p>The shape is the same; the only difference is the sort direction. <code>MaxByAsync</code> orders the key <code>DESC</code>, while <code>MinByAsync</code> keeps the default ascending order, so <code>TOP(1)</code> picks the favorite with the fewest plays.</p>
<h3>Edge case: empty sequences</h3>
<p>One behavior the documentation doesn't mention anywhere: what happens when you call <code>MinByAsync</code> on an empty sequence? In LINQ-to-Objects, <code>MinBy</code>/<code>MaxBy</code> return <code>default</code> for an empty source rather than throwing, but it's worth confirming EF Core's <em>SQL Server translation</em> matches that, since throwing <code>InvalidOperationException</code> on an empty favorites list would be a nasty surprise in production. This test runs against the real Testcontainers SQL Server fixture, not an in-memory provider, so it's actually exercising the translated query rather than LINQ-to-Objects semantics on a fake store:</p>
<pre><code class="language-csharp">[Fact]
public async Task MinBy_on_empty_favorites_should_return_null_not_throw()
{
    // Runs against the real SQL Server fixture. Filtering on a ListenerId that doesn't
    // exist empties the FavoriteTracks result set after translation to SQL, which is what
    // actually exercises MinByAsync's empty-sequence behavior end to end.
    var act = async () =&gt; await fixture.Db.FavoriteTracks
        .Where(f =&gt; f.ListenerId == Guid.NewGuid())
        .Select(f =&gt; f.Track)
        .MinByAsync(t =&gt; t.Plays.Count());

    var result = await act.Should().NotThrowAsync();
    result.Subject.Should().BeNull();
}
</code></pre>
<p>It does match: the query returns <code>null</code>, no exception. This passed on the first try, but it's the kind of edge case worth writing a test for rather than assuming. Undocumented behavior is exactly where LINQ-to-Objects and LINQ-to-Entities most commonly diverge, and only a test against a real relational provider actually proves the translated version agrees.</p>
<h2>Bonus: keeping a to-one join out of split-query collection statements</h2>
<h3>The query</h3>
<p>The last improvement covered here is smaller, and it's easy to demonstrate incorrectly if the to-one navigation in question isn't actually requested. The improvement only shows up when you <code>Include()</code> a to-one reference navigation <em>alongside</em> a collection navigation under <code>AsSplitQuery()</code>: EF Core 11 keeps that to-one join confined to the first statement instead of also dragging it into the later collection statements where it doesn't belong.</p>
<pre><code class="language-csharp">public async Task&lt;ListenerProfileDto?&gt; GetListenerProfileAsync(CancellationToken ct = default)
{
    var listener = await db.Listeners
        .Include(l =&gt; l.PreferredArtist)
        .Include(l =&gt; l.Plays)
        .AsSplitQuery()
        .FirstOrDefaultAsync(ct);

    return listener is null
        ? null
        : new ListenerProfileDto
        {
            DisplayName = listener.DisplayName,
            PreferredArtistName = listener.PreferredArtist?.Name,
            TotalPlays = listener.Plays.Count
        };
}
</code></pre>
<h3>The generated SQL</h3>
<p><code>PreferredArtist</code> is a to-one reference navigation on <code>Listener</code>, and it's explicitly <code>Include()</code>'d here. That matters: a navigation nobody asks for never generates a join in any EF Core version, so leaving out the <code>Include()</code> would demonstrate nothing version-specific. With it included, the split query produces two statements, captured live against the real database:</p>
<pre><code class="language-sql">-- Statement 1: Listener + PreferredArtist. The to-one join lives here, where it belongs.
SELECT TOP(1) [l].[Id], [l].[DisplayName], [l].[PreferredArtistId], [a].[Id], [a].[Country], [a].[Genre], [a].[Name]
FROM [Listeners] AS [l]
LEFT JOIN [Artists] AS [a] ON [l].[PreferredArtistId] = [a].[Id]
ORDER BY [l].[Id]

-- Statement 2: the Plays collection. PreferredArtist / Artists does not appear at all,
-- even though it's Include()'d above. Only the correlation back to the outer Listener
-- (via the [s] subquery) is present.
SELECT [p].[Id], [p].[ListenerId], [p].[PlayDurationSeconds], [p].[PlayedAt], [p].[TrackId], [s].[Id]
FROM (
    SELECT TOP(1) [l].[Id]
    FROM [Listeners] AS [l]
    ORDER BY [l].[Id]
) AS [s]
INNER JOIN [PlayEvents] AS [p] ON [s].[Id] = [p].[ListenerId]
ORDER BY [s].[Id]
</code></pre>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-27-ef-core-11-query-improvements/split-query.png" alt="Split query: where the Artists join ends up before EF Core 11 and in EF Core 11" /></p>
<p>That's the actual improvement: the second statement stays lean. It joins only what it needs to correlate back to the outer query, and doesn't re-join <code>Artists</code> just because a sibling to-one navigation was requested for the first statement. Without this, a split query with several <code>Include()</code>s can accumulate the same unrelated joins across every one of its statements, which is easy to miss in a query plan review until the join count starts climbing for no obvious reason.</p>
<h2>Seeing it as a UI, not just a query plan</h2>
<p>All six queries above back a small Blazor Server app. The two screens most worth a look:</p>
<p><img src="https://raw.githubusercontent.com/abpframework/abp/dev/docs/en/Community-Articles/2026-09-27-ef-core-11-query-improvements/monthly-tops-calendar.png" alt="Monthly Tops rendered as a real calendar page, with the top track pinned like a sticky note" /></p>
<p>The monthly-tops calendar renders one month at a time with a real day grid (<code>DateTime.DaysInMonth</code> and <code>DayOfWeek</code> compute the correct weekday alignment) and pins that month's top track to a day cell like a sticky note. It's a small UI touch that makes the <code>GroupBy</code> + <code>MaxBy</code> result legible at a glance instead of just another table row.</p>
<p>The year-comparison screen renders each of the three buckets (dropped / stayed loyal / newly discovered) as its own single-item carousel driven entirely by Blazor component state and a CSS <code>transform: translateX</code> transition. There's no JavaScript interop, just a <code>FullJoin</code> result split into three buckets.</p>
<h2>Lessons learned</h2>
<ul>
<li><strong>Preview packages mean preview constraints.</strong> <code>net11.0</code>-only TFMs, a Docker-based toolchain and a Docker-in-Docker networking dead end on Windows were all direct consequences of adopting an RC package before the SDK it depends on has reached GA. None of it is a defect in EF Core 11; it's the cost of going first.</li>
<li><strong>&quot;Compiles to SQL&quot; and &quot;compiles to one SQL statement&quot; are different claims.</strong> The official docs make the first claim about <code>GroupBy</code> + <code>MaxBy</code>; only an interceptor-based test proves the second. Don't take single-round-trip behavior on faith for a translation this new.</li>
<li><strong>Count both sync and async interceptor hooks.</strong> A <code>DbCommandInterceptor</code> that only overrides <code>ReaderExecuting</code> will silently under-count any workload that calls <code>...Async()</code> methods, which is most modern EF Core code, and the resulting test failure can look like a query-translation bug instead of an interceptor bug.</li>
<li><strong>An improvement demo has to actually exercise the thing it claims to demonstrate.</strong> The split-query example is only meaningful because <code>PreferredArtist</code> is actually <code>Include()</code>'d; a navigation nobody requests generates no join in any EF Core version, so without the <code>Include()</code> the &quot;improvement&quot; would be an empty comparison.</li>
<li><strong>Undocumented edge cases are worth a dedicated test against the real provider, not the closest convenient one.</strong> The empty-sequence behavior of <code>MinByAsync</code> was confirmed against the actual SQL Server translation, because an in-memory provider only proves LINQ-to-Objects-style semantics, not what EF Core 11 sends to SQL Server.</li>
</ul>
<h2>Adoption checklist</h2>
<p>Before moving reporting code onto EF Core 11:</p>
<ul>
<li>[ ] <strong>Target framework.</strong> EF Core 11 packages target <code>net11.0</code> only, so the project has to move to .NET 11 first.</li>
<li>[ ] <strong>Release stage.</strong> EF Core 11 is currently a release candidate. Decide whether that's acceptable for your deployment, or wait for GA in November 2026.</li>
<li>[ ] <strong><code>FullJoin</code> candidates.</strong> Search for <code>GroupJoin</code> + <code>DefaultIfEmpty</code> + <code>Concat</code>, or <code>LeftJoin</code> + <code>RightJoin</code> + <code>Concat</code> combinations. Each one is a candidate for a single <code>FullJoin</code>.</li>
<li>[ ] <strong>Per-group winners.</strong> Look for &quot;top item per group&quot; queries currently done in memory or with raw SQL. <code>GroupBy</code> + <code>MaxBy</code>/<code>MinBy</code> may now translate them.</li>
<li>[ ] <strong>Round-trip tests.</strong> For queries where &quot;one round-trip&quot; matters, add a command-counting interceptor test, and override both the sync and async hooks.</li>
<li>[ ] <strong>Empty sequences.</strong> If a <code>MinBy</code>/<code>MaxBy</code> query can run over an empty set, test that case against the real provider.</li>
<li>[ ] <strong>Split queries.</strong> If you use <code>AsSplitQuery()</code> with reference <code>Include()</code>s, re-capture the generated SQL after upgrading so you can see the joins that disappeared.</li>
<li>[ ] <strong>Performance.</strong> Benchmark against your own data before claiming a speed-up. This article measures correctness, not speed.</li>
</ul>
<h2>Conclusion</h2>
<p>None of these features is dramatic on its own. <code>FullJoin</code> replaces a few lines of <code>LeftJoin</code>/<code>RightJoin</code>/<code>Concat</code> plumbing, <code>GroupBy</code> + <code>MaxBy</code> turns a query EF Core couldn't translate before into a single round-trip, and the split-query change quietly drops a join you never needed. Together, though, they make the kind of reporting code every business application eventually needs easier to write and easier to trust.</p>
<p>The bigger takeaway is about <em>how</em> to evaluate a release like this. A changelog tells you that a translation exists; only the generated SQL and a test that counts round-trips tell you what it actually does. This article deliberately focuses on correctness rather than speed: it shows what EF Core 11 sends to SQL Server, not how much faster that is. If performance is your reason to upgrade, benchmark against your own data before deciding. If your reason is writing reporting queries that say what you mean and compile to the SQL you would have written by hand, EF Core 11 already delivers that in its release candidate.</p>
<h2>References</h2>
<ul>
<li><a href="https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-11.0/whatsnew">What's new in EF Core 11</a>: <a href="https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-11.0/whatsnew#support-for-the-new-net-11-fulljoin-operator"><code>FullJoin</code> support</a>, <a href="https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-11.0/whatsnew#groupby-enhancements"><code>GroupBy</code> enhancements</a>, <a href="https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-11.0/whatsnew#maxby-and-minby"><code>MaxBy</code> and <code>MinBy</code></a>, <a href="https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-11.0/whatsnew#better-sql-for-to-one-joins">better SQL for to-one joins</a></li>
<li><a href="https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-10.0/whatsnew#support-for-the-net-10-leftjoin-and-rightjoin-operators">What's new in EF Core 10: <code>LeftJoin</code> and <code>RightJoin</code></a></li>
<li><a href="https://learn.microsoft.com/en-us/ef/core/querying/complex-query-operators">Complex query operators</a> (joins and <code>GroupBy</code> translation)</li>
<li><a href="https://learn.microsoft.com/en-us/ef/core/querying/single-split-queries">Single vs. split queries</a></li>
<li><a href="https://learn.microsoft.com/en-us/ef/core/querying/client-eval">Client vs. server evaluation</a> (why untranslatable queries throw instead of running on the client)</li>
<li><a href="https://learn.microsoft.com/en-us/ef/core/logging-events-diagnostics/interceptors">Interceptors</a> (<code>DbCommandInterceptor</code>)</li>
<li><a href="https://dotnet.testcontainers.org/">Testcontainers for .NET</a></li>
<li><a href="https://dotnet.microsoft.com/en-us/download/dotnet/11.0">Download .NET 11</a></li>
</ul>
]]></content:encoded>
      <media:thumbnail url="https://abp.io/api/posts/cover-picture-source/3a23f901-28a6-ffa8-979f-e22aa27cc0ce" />
      <media:content url="https://abp.io/api/posts/cover-picture-source/3a23f901-28a6-ffa8-979f-e22aa27cc0ce" medium="image" />
    </item>
  </channel>
</rss>