Themenseite · Entity Framework & SQL Server

Ein Contains(), vier EF-Core-Versionen: gleicher Code, anderes SQL

Eine Zeile LINQ, ids.Contains(p.Id), gehört zu den häufigsten Filtern in EF-Anwendungen. Welches SQL daraus wird, hat sich von EF Core 7 bis EF Core 10 zweimal grundlegend geändert, ohne dass jemand den Anwendungscode anfasst.

Stand 03.11.2026 · ca. 9 Minuten

Lab EF Core 7–10SQL Server 2022 · 2025
LINQids.Contains(p.Id)EF Core 7KonstantenIN (1, 2, 3)EF Core 8 / 9JSON-ParameterOPENJSON(@ids)EF Core 10ParameterlisteIN (@ids1,@ids2,@ids3, …)Gleiche LINQ-Zeile, je nach EF-Core-Version anderes SQL.

Im Lab haben wir dieselbe Abfrage mit EF Core 7, 8, 9 und 10 gegen SQL Server 2022 und 2025 laufen lassen. Ergebnis: Jede Version hat ihre Stärke und ihren blinden Fleck. EF Core 7 füllt den Plan Cache, EF Core 8 und 9 entlasten ihn, wählen aber bei schief verteilten Daten einen Plan, der im Lab mehr als doppelt so viel CPU und dreißigmal so viele Lesezugriffe kostete wie nötig. EF Core 10 liegt dazwischen und lässt dafür den ersten Aufruf über den Plan entscheiden. Welche Variante für Ihre Abfrage passt, hängt von den Daten ab, nicht von der Versionsnummer.

Drei Übersetzungen derselben Zeile

var produkte = await db.Products
    .Where(p => ids.Contains(p.Id))
    .ToListAsync();

So sieht die Bedingung [p].[Id] … in der WHERE-Klausel aus, gekürzt aus den Labprotokollen:

EF CoreSQL für drei WerteParameter
7IN (1300871, 877705, 1518750)keine, die Werte stehen im Text
8 und 9IN (SELECT … FROM OPENJSON(@__ids_0) …)einer, die Liste als JSON-Text
10IN (@ids1, @ids2, @ids3)je Wert einer

Ausgeschrieben lautet die Form von EF Core 8 und 9:

WHERE [p].[Id] IN (
    SELECT [i].[value]
    FROM OPENJSON(@__ids_0) WITH ([value] int '$') AS [i]
)

Ein paar Details aus den Messungen, die im Alltag zählen:

  • EF Core 7 schreibt die Werte in den SQL-Text. Jede andere Liste ergibt einen anderen Text. Bei 3.000 Werten war der Befehl gut 25.000 Zeichen lang.
  • EF Core 8 und 9 schicken immer denselben Text. Der Parameter ist bis zu einer gewissen Länge nvarchar(4000), bei langen Listen nvarchar(max); im Lab lag der Wechsel zwischen 100 und 1.000 Werten. Auch eine leere Liste geht über OPENJSON.
  • EF Core 10 nimmt je Wert einen eigenen Parameter und füllt die Liste auf feste Größen auf: Für 1 bis 50 Werte entstanden nur zehn verschiedene Texte (1, 2, 3, 4, 5, 10, 20, 30, 40 und 50 Parameter). Die aufgefüllten Plätze wiederholen den letzten Wert. Ab 2.100 Werten, der Grenze von SQL Server für Parameter je Befehl, wechselt EF Core 10 auf OPENJSON. Die Parameter heißen außerdem kürzer (@ids1 statt @__ids_0).

Alle Varianten lieferten in allen 392 geprüften Fällen dieselben Zeilen. Es geht hier also nicht um Richtigkeit, sondern um Kosten.

Plan Cache: 500 Texte, einer oder zehn

Im ersten Versuch ruft die Anwendung die Abfrage 500-mal auf, mit zufälligen Listen von 1 bis 50 Werten, wie es etwa eine Merkliste oder eine Mehrfachauswahl im Filter tut. Gemessen auf SQL Server 2022, Median aus fünf Wiederholungen:

EF CoreTexte im Plan CacheSpeicher im Plan CacheKompilierungenSQL-CPU je AufrufAntwortzeit je Aufruf (Median)
7 (Konstanten)50039,6 MBrund 5004,5 ms3,5 ms
8 und 9 (JSON-Parameter)10,2 MBpraktisch keine0,86 ms1,9 ms
10 (Parameterliste)101,4 MBrund 101,0 ms2,3 ms
Speicher im Plan Cache (MB)EF 7 · Konstanten500 Pläne39,6 MBEF 8/9 · JSON-Parameter1 Plan0,2 MBEF 10 · Parameterliste10 Pläne1,4 MB010203040SQL-CPU je Aufruf (ms)EF 7 · Konstanten4,54 msEF 8/9 · JSON-Parameter0,86 msEF 10 · Parameterliste1,02 ms012345
Speicher im Plan Cache (MB)EF 7 · Konstanten500 Pläne39,6 MBEF 8/9 · JSON-Parameter1 Plan0,2 MBEF 10 · Parameterliste10 Pläne1,4 MB010203040SQL-CPU je Aufruf (ms)EF 7 · Konstanten4,54 msEF 8/9 · JSON-Parameter0,86 msEF 10 · Parameterliste1,02 ms012345
500 Aufrufe mit zufälligen Listen von 1 bis 50 Werten nacheinander über eine Verbindung: Speicher im Plan Cache und SQL-CPU je Aufruf, Median aus fünf Wiederholungen. PSG-Lab, 03.11.2026, SQL Server 2022 CU27, synthetische Daten.

Mit Konstanten kompiliert SQL Server fast jeden Aufruf neu. Das kostet CPU und füllt den Plan Cache mit Einmal-Plänen, die andere, wichtigere Pläne verdrängen können. Microsoft nennt das in der Dokumentation zu EF Core 10 ausdrücklich als Grund für die Änderung. Bis hierhin sieht EF Core 8 wie der klare Sieger aus.

Schätzung und Plan bei schiefen Daten

Der zweite Versuch filtert nicht nach dem Primärschlüssel, sondern nach einer Kategorie:

var preise = await db.Products
    .Where(p => kategorien.Contains(p.CategoryId))
    .Select(p => new { p.Id, p.Price })
    .ToListAsync();

Die Tabelle hat 2 Millionen Zeilen. Eine Kategorie ist groß (800.000 Zeilen), 50.000 Kategorien sind klein (je 20 Zeilen). Solche Verteilungen sind in echten Daten normal: ein Hauptmandant, ein Standardstatus, ein Großkunde. Die Anwendung fragt einmal fünf kleine Kategorien ab (100 Zeilen) und einmal eine Liste, die die große enthält (800.080 Zeilen).

Für 100 Zeilen ist ein Index Seek mit Lookup richtig, für 800.000 Zeilen ein Scan der Tabelle. Was SQL Server wählt, hängt davon ab, was er über die Werte weiß (CPU je Ausführung, Median der wiederholten Ausführungen):

EF CoreWas SQL Server siehtPlan für 100 ZeilenPlan für 800.080 Zeilen
7 (Konstanten)die konkreten WerteSeek, 0,7 ms CPU, 333 ReadsScan, 0,9 s CPU, 81.380 Reads
8 und 9 (JSON-Parameter)einen Text, dessen Inhalt er nicht kenntSeek, 0,8 ms CPUSeek, 2,0 s CPU, 2,55 Mio. Reads
10, erster Aufruf mit kleinen Kategoriendie Werte des ersten AufrufsSeek, 0,8 ms CPUSeek, 2,0 s CPU, 2,55 Mio. Reads
10, erster Aufruf mit der großen Kategoriedie Werte des ersten AufrufsScan, 385 ms CPU, 81.380 ReadsScan, 0,9 s CPU
passender Planunpassender PlanListe mit 100 Zeilenpassender Plan: Index Seek mit LookupEF 7 · Konstanten0,7 msEF 8/9 · JSON-Parameter0,8 msEF 10 · erst kleine Liste0,8 msEF 10 · erst große Liste385 msListe mit 800.080 Zeilenpassender Plan: Scan der TabelleEF 7 · Konstanten902 msEF 8/9 · JSON-Parameter1.967 msEF 10 · erst kleine Liste1.999 msEF 10 · erst große Liste895 ms1101001.000CPU je Ausführung (ms, logarithmische Achse)
passender Planunpassender PlanListe mit 100 Zeilenpassender Plan: Index Seek mit LookupEF 7 · Konstanten0,7 msEF 8/9 · JSON-Parameter0,8 msEF 10 · erst kleine Liste0,8 msEF 10 · erst große Liste385 msListe mit 800.080 Zeilenpassender Plan: Scan der TabelleEF 7 · Konstanten902 msEF 8/9 · JSON-Parameter1.967 msEF 10 · erst kleine Liste1.999 msEF 10 · erst große Liste895 ms1101001.000CPU je Ausführung (ms, logarithmische Achse)
CPU je Ausführung (warm, Median) für eine kleine und eine große Liste auf einer schief verteilten Spalte; logarithmische Achse, damit 0,7 ms und 2.000 ms lesbar bleiben. Orange: der Plan passt nicht zur Liste. Der Wert für EF 8/9 stammt von EF 8, EF 9 liegt bei 1.945 ms. PSG-Lab, 03.11.2026, SQL Server 2022 CU27, synthetische Daten.

Was dahintersteckt:

  • Mit dem JSON-Parameter schätzt SQL Server blind. Er weiß nicht, welche Werte im JSON stehen, und rechnet mit einer festen Annahme: im Lab 282,7 Zeilen, egal welche Liste kommt. Das passt für die kleinen Kategorien und ist für die große um den Faktor 2.800 daneben. Der Plan bleibt immer derselbe: viele Einzelzugriffe über den Index, für 800.000 Zeilen 2,55 Millionen gelesene Seiten statt 81.380.
  • Mit der Parameterliste von EF Core 10 schaut SQL Server beim ersten Aufruf auf die Werte und optimiert für genau diese. Das ist klassisches Parameter Sniffing: Kam zuerst die kleine Liste, bekommt die große einen zu kleinen Plan. Kam zuerst die große, scannt die kleine Liste die ganze Tabelle, 385 ms CPU statt 0,7 bis 0,8 ms, mehr als das 400-Fache. Nach einem Neustart oder einer Statistikaktualisierung kann es jeweils anders herum sein. Das typische Bild „mal schnell, mal langsam“ beschreibt der Performance-Ratgeber.
  • Mit Konstanten bekommt jede Liste ihren passenden Plan, zum Preis der Kompilierungen aus dem ersten Versuch.

Für den Primärschlüssel aus dem ersten Versuch spielt das keine Rolle: Jeder Wert trifft genau eine Zeile, jede Schätzung passt. Die Frage „welche Übersetzung ist besser“ hat deshalb keine allgemeine Antwort. Sie hängt an der Spalte.

Wenn der Compatibility Level nicht passt

OPENJSON setzt einen Compatibility Level der Datenbank von mindestens 130 voraus. Datenbanken, die nach einem Server-Upgrade auf einem alten Level stehen geblieben sind, gibt es häufiger, als man denkt. Im Lab mit Level 120:

  • EF Core 8 scheiterte bei jeder Liste, auch der leeren, mit Fehler 102 (Incorrect syntax near '$'.). Das passiert nicht beim Upgrade, sondern beim ersten Aufruf in Produktion.
  • EF Core 10 lief mit Level 120 zunächst problemlos, weil es Parameter statt JSON verwendet. Erst ab 2.100 Werten wechselt es auf OPENJSON und scheitert dann mit demselben Fehler. Ein Test mit kleinen Listen bemerkt das nicht.
  • UseCompatibilityLevel(120) in der Provider-Konfiguration bringt EF dazu, wieder Konstanten zu schreiben. Es ändert aber nicht den Compatibility Level der Datenbank, sondern nur, welches SQL EF erzeugt. Die Datenbank bleibt, wie sie ist.

Prüfen Sie deshalb vor dem Update den tatsächlichen Level: SELECT name, compatibility_level FROM sys.databases;

Steuern statt hinnehmen

Seit EF Core 8.0.2 und vollständig ab EF Core 9 lässt sich die Übersetzung steuern, global oder für eine einzelne Abfrage. Im Lab haben alle Varianten gebaut, liefen fehlerfrei und erzeugten die erwartete Form:

  • Für eine einzelne Abfrage (EF Core 9 und 10): EF.Constant(ids) schreibt Konstanten, EF.Parameter(ids) schickt einen JSON-Parameter. EF Core 10 kennt zusätzlich EF.MultipleParameters(ids) für die Parameterliste.
  • Als Standard für alle Abfragen in EF Core 9: in der Provider-Konfiguration die Methoden der Familie TranslateParameterizedCollections…, also …ToConstants() bzw. …ToParameters().
  • Als Standard für alle Abfragen in EF Core 10: UseParameterizedCollectionMode(…) mit ParameterTranslationMode.Constant, .Parameter oder .MultipleParameters.

Eine Faustregel aus den Messungen, keine Garantie für Ihre Daten:

  • Gleichmäßig verteilte Spalten (Primärschlüssel, eindeutige Nummern): JSON-Parameter oder Parameterliste. Ein Plan für alle passt, der Plan Cache bleibt schlank.
  • Schief verteilte Spalten (Status, Mandant, Kategorie mit Ausreißern): EF.Constant für genau diese Abfrage, wenn sie nicht hundertmal je Sekunde läuft. Die Kompilierung kostet hier weniger als ein falscher Plan.
  • Häufige Abfrage auf schiefer Spalte: Das ist kein EF-Thema mehr. Dann helfen getrennte Abfragen für die großen Werte, ein passender Index oder eine Planführung auf SQL-Server-Seite.

Mit EF.Constant gab es im Lab eine Überraschung: Unter EF Core 8 brauchte derselbe Aufruf im Client fast doppelt so lange wie mit EF Core 7 (6,6 statt 3,5 ms), obwohl das SQL identisch war. Unter EF Core 9 und 10 war der Unterschied verschwunden. Wir haben die Ursache nicht weiter untersucht.

Ein Upgrade prüfen

Am 10. November 2026 endet der Support für EF Core 8 und 9. Wer jetzt auf EF Core 10 umstellt, ändert das SQL vieler Contains()-Abfragen, und mit den neuen Parameternamen auch den Text jeder anderen Abfrage mit Parametern. Nach dem Update kompiliert SQL Server diese Anweisungen deshalb neu. Ein kurzer Prüfplan:

  1. Compatibility Level der Datenbanken prüfen, bevor EF Core 10 Listen mit mehr als 2.100 Werten über OPENJSON schickt.
  2. Die wichtigsten Abfragen mit Listen finden. Im Code nach .Contains( auf Listen suchen, im Query Store nach OPENJSON(@ oder langen Parameterlisten. Die Abfrage auf der Übersichtsseite zeigt beides.
  3. Für jede dieser Abfragen klären, ob die Spalte schief verteilt ist. Eine einfache Gruppierung nach der Spalte mit COUNT(*) reicht.
  4. Vor und nach dem Update mit realistischen Daten messen, und zwar mit großen und kleinen Listen in beiden Reihenfolgen. Ein einzelner Testlauf zeigt nur den Plan, der zufällig zuerst kompiliert wurde.
  5. Erst EF, dann den Compatibility Level ändern, nicht beides auf einmal. Sonst lässt sich nicht sagen, was eine Veränderung ausgelöst hat.

Weitere EF-Themen: Übersicht · Include, Projektion und Split Query

Quellen

Weitere EF-Themen