Masz kolekcję obiektów, filtrujesz ją za pomocą LINQ i chcesz wykonać operację na każdym znalezionym elemencie? Najczęściej najlepszym rozwiązaniem będzie zwykła pętla foreach, ale sposób zapisania zapytania, moment jego wykonania i wybór między foreach, ForEach oraz ToList() mają duże znaczenie. Pokażę praktyczne przykłady, typowe błędy i sytuacje, w których pozornie poprawny kod może wykonać się więcej razy, niż zakładasz.
Najważniejsze zasady iterowania po wynikach LINQ
-
foreachwykonuje zapytanie LINQ, gdy zaczyna odczytywać jego elementy. -
Wherefiltruje, aSelectprzekształca dane przed przejściem po kolekcji. -
ForEachnie jest metodą LINQ, tylko metodą klasyList. -
ToList()materializuje wynik i zatrzymuje go w pamięci jako listę. - Przy
IQueryabletrzeba uważać na wielokrotne wykonanie zapytania do bazy.

LINQ i foreach w C# w najprostszym wariancie
Najbardziej czytelny schemat wygląda tak: najpierw buduję zapytanie, a później przechodzę po jego wynikach za pomocą foreach. W tym przykładzie interesują mnie tylko aktywne produkty droższe niż 100 zł.
var products = new[]
{
new { Name = "Klawiatura", Price = 180m, IsActive = true },
new { Name = "Mysz", Price = 80m, IsActive = true },
new { Name = "Monitor", Price = 900m, IsActive = false }
};
var expensiveActiveProducts = products
.Where(product => product.IsActive && product.Price > 100)
.OrderBy(product => product.Price);
foreach (var product in expensiveActiveProducts)
{
Console.WriteLine($"{product.Name}: {product.Price} zł");
}Where ogranicza zbiór, a OrderBy ustala kolejność. Samo przypisanie wyniku do zmiennej nie musi jeszcze uruchomić całej logiki. W przypadku typowych operatorów LINQ wynik jest obliczany dopiero wtedy, gdy zaczynam go enumerować, na przykład właśnie w pętli foreach.
To ważne, bo zmienna expensiveActiveProducts przechowuje przede wszystkim opis operacji, a niekoniecznie gotową listę elementów. Dlatego można powiedzieć, że pętla jest nie tylko sposobem odczytu danych, ale także momentem wykonania zapytania.
Co dokładnie robi foreach podczas iterowania
Pętla foreach korzysta z mechanizmu enumeracji, czyli odczytu kolejnych elementów sekwencji. Dla IEnumerable compiler wykorzystuje enumerator i wywołuje go krok po kroku, dzięki czemu nie musisz ręcznie obsługiwać indeksu ani sprawdzać, czy kolekcja ma jeszcze dane.
var numbers = Enumerable.Range(1, 10)
.Where(number => number % 2 == 0)
.Select(number => number * 10);
foreach (var number in numbers)
{
Console.WriteLine(number);
}Wynikiem będą liczby 20, 40, 60, 80 i 100. Najpierw działa filtr, później projekcja, czyli przekształcenie każdego elementu. Kolejność operatorów ma znaczenie, ponieważ wcześniejsze odfiltrowanie danych może ograniczyć liczbę elementów przetwarzanych przez następne operacje.
W praktyce często spotykam też próbę użycia Select do wykonywania efektów ubocznych, na przykład zapisu do logu. To zwykle zły kierunek, ponieważ Select służy do tworzenia nowej sekwencji. Gdy chcę wykonać akcję dla każdego elementu, wybieram foreach, bo kod od razu pokazuje swoją intencję.
foreach, ForEach i ToList mają różne zadania
Te trzy konstrukcje bywają wrzucane do jednego worka, choć rozwiązują różne problemy. Największe nieporozumienie polega na tym, że ForEach nie jest standardowym operatorem LINQ. Jest metodą dostępną między innymi dla List.
| Rozwiązanie | Kiedy działa | Najważniejsza cecha |
|---|---|---|
foreach |
Na dowolnym obiekcie możliwym do enumeracji | Obsługuje break, continue i wyjątki w naturalny sposób |
List |
Tylko na liście | Przyjmuje akcję w postaci delegata, ale gorzej skaluje się przy bardziej złożonej logice |
ToList() |
Na sekwencji LINQ | Materializuje wynik i zapisuje elementy w pamięci |
var names = users
.Where(user => user.IsActive)
.Select(user => user.Name)
.ToList();
foreach (var name in names)
{
Console.WriteLine(name);
}W tym wariancie ToList() kończy budowanie zapytania i tworzy konkretną listę. Przydaje się, gdy chcę wykonać zapytanie tylko raz, użyć wyników kilka razy albo mieć możliwość dodawania i usuwania elementów z kolekcji wynikowej.
Nie dodaję jednak ToList() automatycznie. Jeśli dane są duże, materializacja może niepotrzebnie zwiększyć zużycie pamięci. Gdy potrzebuję tylko jednorazowo przejść po wynikach, bezpośredni foreach jest prostszy i często korzystniejszy.
Wykonanie odroczone potrafi zmienić wynik
Wykonanie odroczone oznacza, że zapytanie LINQ jest obliczane dopiero przy enumeracji. Dzięki temu mogę zbudować zapytanie wcześniej, a dane zostaną odczytane dopiero wtedy, gdy naprawdę będą potrzebne. Ta wygoda ma jednak konsekwencję: wynik może zależeć od stanu źródła w momencie wykonania.
var numbers = new List { 1, 2, 3 };
var evenNumbers = numbers.Where(number => number % 2 == 0);
numbers.Add(4);
foreach (var number in evenNumbers)
{
Console.WriteLine(number);
} Wynik obejmie również liczbę 4, ponieważ lista została zmieniona przed wykonaniem pętli. Gdy zależy mi na zatrzymaniu konkretnego stanu danych, tworzę kopię przez ToList().
var evenNumbersSnapshot = numbers
.Where(number => number % 2 == 0)
.ToList();
numbers.Add(6);
foreach (var number in evenNumbersSnapshot)
{
Console.WriteLine(number);
}Drugi ważny problem to wielokrotna enumeracja. Każde przejście po zapytaniu może ponownie uruchomić filtr, sortowanie, a przy IQueryable także zapytanie do bazy. Jeśli wynik będzie używany kilka razy, świadomie zdecyduj, czy potrzebujesz materializacji, zamiast liczyć na przypadkowe zachowanie.
Praktyczne przykłady z projekcją i grupowaniem
Największa siła połączenia LINQ z foreach pojawia się wtedy, gdy pętla dostaje już dane przygotowane dokładnie do konkretnego zadania. Nie muszę w środku sprawdzać wszystkich warunków, pobierać właściwości i budować obiektów pomocniczych.
var reportRows = orders
.Where(order => order.Status == OrderStatus.Paid)
.Select(order => new
{
order.Id,
CustomerName = order.Customer.Name,
Total = order.Items.Sum(item => item.Price * item.Quantity)
})
.OrderByDescending(row => row.Total);
foreach (var row in reportRows)
{
Console.WriteLine(
$"Zamówienie {row.Id}, klient: {row.CustomerName}, suma: {row.Total} zł");
}Ten styl ogranicza odpowiedzialność pętli do prezentacji lub wykonania działania. W kodzie produkcyjnym szczególnie cenię sobie takie rozdzielenie, bo warunki i obliczenia są w zapytaniu, a efekt końcowy w pętli.
Jeśli logika filtrowania robi się dłuższa, wydzielam ją do osobnej metody. W projektach, w których często korzystam z pomocniczych funkcji bez stanu obiektu, przydatne może być także użycie metod static w C#. Trzeba jednak pamiętać, że metoda przekazywana do LINQ przez Entity Framework musi być możliwa do przetłumaczenia na zapytanie źródłowe, jeśli pracujemy na IQueryable.
Przy grupowaniu pojawia się z kolei naturalna pętla zagnieżdżona.
var productsByCategory = products
.Where(product => product.IsActive)
.GroupBy(product => product.Category);
foreach (var group in productsByCategory)
{
Console.WriteLine(group.Key);
foreach (var product in group)
{
Console.WriteLine($"- {product.Name}");
}
}To czytelniejsze niż ręczne budowanie słownika, gdy potrzebuję tylko przejść po kategoriach i ich elementach. GroupBy porządkuje dane przed iteracją, a nie zastępuje samej pętli.
Typowe błędy przy iterowaniu wyników LINQ
Modyfikowanie źródłowej kolekcji w pętli
Usuwanie elementów z listy podczas działania foreach zwykle kończy się wyjątkiem albo pominięciem części danych. Bezpieczniej utworzyć kopię elementów do usunięcia, użyć RemoveAll albo przygotować nową listę za pomocą Where i ToList().
var remainingUsers = users
.Where(user => !user.IsBlocked)
.ToList();Wykonywanie tego samego zapytania kilka razy
Ten kod może wielokrotnie przejść po źródle:
if (query.Any())
{
Console.WriteLine(query.Count());
}
foreach (var item in query)
{
Process(item);
}Przy zwykłej tablicy będzie to przede wszystkim dodatkowa praca procesora. Przy źródle bazodanowym może oznaczać trzy osobne operacje. Jeżeli potrzebuję jednego zestawu danych, często materializuję go raz i dopiero później sprawdzam jego zawartość.
Mylenie IEnumerable z IQueryable
IEnumerable oznacza przetwarzanie elementów po stronie aplikacji. IQueryable pozwala dostawcy, na przykład ORM, przełożyć część operacji na zapytanie źródłowe. To rozróżnienie wpływa na wydajność, liczbę pobieranych kolumn i ilość danych przesyłanych z bazy.
Przeczytaj również: Async i await w C# bez blokowania wątku - praktyczny przewodnik
Nieprawidłowe porównywanie statusów
Jeżeli elementy mają status zapisany jako typ wyliczeniowy, filtr powinien porównywać wartości tego samego typu, a nie przypadkowe liczby lub teksty.
var paidOrders = orders
.Where(order => order.Status == OrderStatus.Paid);
foreach (var order in paidOrders)
{
Process(order);
}Przy pracy ze statusami i flagami dobrze znać zasady bezpiecznego użycia enum w C#. Czytelna nazwa wartości zwykle wygrywa z magiczną liczbą, ale sam typ wyliczeniowy nie zabezpieczy aplikacji przed błędnym mapowaniem danych z zewnętrznego źródła.
Kiedy zwykły foreach nie wystarczy
Standardowa pętla jest właściwa dla sekwencji synchronicznych. Jeśli dane napływają asynchronicznie, na przykład z paginowanego API albo strumienia, można potrzebować await foreach i typu IAsyncEnumerable.
await foreach (var message in GetMessagesAsync())
{
await HandleMessageAsync(message);
}Nie zamieniam jednak zwykłego foreach na wersję asynchroniczną tylko dlatego, że kod zawiera async. Asynchroniczna enumeracja ma sens wtedy, gdy samo źródło dostarcza dane etapami. Dla gotowej listy w pamięci niczego istotnego nie poprawi.
Podobnie nie każdy problem wymaga LINQ. Gdy pętla ma skomplikowaną logikę, kilka warunków wyjścia i obsługę błędów, klasyczny foreach bywa po prostu łatwiejszy do testowania. Zwięzłość zapisu jest zaletą tylko wtedy, gdy nie ukrywa działania kodu.
Dobry domyślny wybór dla codziennego kodu C#
Najczęściej zaczynam od zapytania LINQ, które filtruje, sortuje lub projektuje dane, a kończę je zwykłym foreach. Dostaję wtedy czytelny podział odpowiedzialności: LINQ przygotowuje dane, a pętla wykonuje operację.
ToList() dodaję świadomie, gdy potrzebuję migawki danych, wielokrotnego użycia wyniku albo kolekcji, którą będę modyfikować. Z kolei List zostawiam dla bardzo krótkich akcji, w których brak break i continue nie będzie problemem.
Jeśli zapamiętasz jedną rzecz, niech będzie nią moment wykonania zapytania. To właśnie on najczęściej decyduje o tym, czy kod będzie przewidywalny, wydajny i bezpieczny w aplikacji .NET.
