Programmazione

LINQ in C#: guida completa alle operazioni di aggregazione (Count, Sum, MinBy, MaxBy, Aggregate)

Dario Fadda Maggio 14, 2026

Le operazioni di aggregazione sono tra le più frequenti in qualsiasi applicazione .NET che lavora con collezioni di dati. LINQ mette a disposizione un toolkit completo: dagli operatori di base come Count e Sum, alle più potenti MinBy/MaxBy introdotte in .NET 6, fino ad Aggregate, il fold generico che permette di sostituire loop complessi con pipeline dichiarative. In questo articolo approfondiamo ogni operatore con esempi pratici, evidenziamo le trappole di performance più comuni e mostriamo come costruire aggregazioni efficienti anche in scenari avanzati.

Il modello di dominio

Tutti gli esempi utilizzano tre record che rappresentano un sistema di ordini e vendite:

public record Order(int Id, string CustomerId, string Status, decimal Total, DateTimeOffset PlacedAt);
public record Product(int Id, string Name, string Category, decimal Price, int StockLevel);
public record SalesData(string Region, string ProductId, int UnitsSold, decimal Revenue, DateTimeOffset Period);


Count e LongCount

Count() restituisce il numero di elementi come int. La versione con predicato conta solo gli elementi che soddisfano la condizione:

IEnumerable<Order> orders = GetOrders();

int total        = orders.Count();
int pendingCount = orders.Count(o => o.Status == "Pending");
int paidCount    = orders.Count(o => o.Status == "Paid");

Console.WriteLine($"Totale: {total}, In attesa: {pendingCount}, Pagati: {paidCount}");


Per sequenze con più di int.MaxValue elementi (circa 2,1 miliardi), usare LongCount() che restituisce long. Nella pratica, questo scenario si presenta principalmente in contesti di log aggregation o telemetria su larga scala.

La trappola di performance: Count vs Any

Questo è uno degli errori di performance più diffusi con LINQ. Quando si vuole verificare l’esistenza di almeno un elemento, molti sviluppatori usano Count() > 0 invece di Any():

IEnumerable<Order> orders = GetOrders();

// ❌ Count() valuta l'intera sequenza — O(n)
if (orders.Count(o => o.Status == "Pending") > 0)
{
    Console.WriteLine("Ci sono ordini in attesa.");
}

// ✅ Any() si ferma al primo match — O(1) nel caso migliore
if (orders.Any(o => o.Status == "Pending"))
{
    Console.WriteLine("Ci sono ordini in attesa.");
}


Any(predicate) cortocircuita non appena trova il primo elemento corrispondente. Count(predicate) deve valutare ogni elemento per produrre un conteggio accurato, anche quando la risposta a “esiste almeno un elemento?” sarebbe nota immediatamente. La differenza di performance è particolarmente evidente con sorgenti lazy, query EF Core o sequenze molto lunghe.

Stesso principio per il test di sequenza vuota:

// ❌ Enumera tutta la sequenza
if (orders.Count() == 0) { }

// ✅ Si ferma al primo elemento
if (!orders.Any()) { }


La regola da memorizzare: usare Any() per verifiche di esistenza, Count() solo quando serve il numero esatto.

Sum e Average

Sum(selector) e Average(selector) accettano un selettore per proiettare ogni elemento a un valore numerico prima di aggregare:

IEnumerable<Order> orders = GetOrders();

decimal totalRevenue = orders.Sum(o => o.Total);
decimal averageOrder = orders.Average(o => o.Total);

Console.WriteLine($"Fatturato totale: €{totalRevenue:F2}");
Console.WriteLine($"Ordine medio: €{averageOrder:F2}");


Importante: Average() lancia InvalidOperationException su una sequenza vuota. È buona pratica proteggersi con un controllo preventivo:

decimal? safeAverage = orders.Any()
    ? orders.Average(o => o.Total)
    : null;


Un esempio più articolato che combina GroupBy con Sum e Average per ottenere statistiche per regione geografica:

IEnumerable<SalesData> sales = GetSalesData();

var revenueByRegion = sales
    .GroupBy(s => s.Region)
    .Select(g => new
    {
        Region       = g.Key,
        TotalRevenue = g.Sum(s => s.Revenue),
        AverageUnits = g.Average(s => s.UnitsSold)
    })
    .OrderByDescending(x => x.TotalRevenue);

foreach (var row in revenueByRegion)
{
    Console.WriteLine($"{row.Region}: €{row.TotalRevenue:F0}, media {row.AverageUnits:F1} unità");
}


Min e Max

Min(selector) e Max(selector) restituiscono il valore scalare minimo o massimo — non l’elemento che lo contiene:

IEnumerable<Product> products = GetProducts();

decimal cheapest      = products.Min(p => p.Price);
decimal mostExpensive = products.Max(p => p.Price);

Console.WriteLine($"Range prezzi: €{cheapest:F2} — €{mostExpensive:F2}");


Il limite: se serve il prodotto più economico (non solo il suo prezzo), prima di .NET 6 si era costretti a enumerare la sequenza due volte:

// Prima di .NET 6 — due passaggi, potenziale bug con prezzi duplicati
decimal minPrice     = products.Min(p => p.Price);
Product? cheapestOld = products.FirstOrDefault(p => p.Price == minPrice);


Questo approccio ha un bug sottile: se più prodotti condividono il prezzo minimo, FirstOrDefault sceglie il primo trovato nell’ordine di iterazione, che potrebbe non essere quello desiderato. Inoltre itera la sequenza due volte.

MinBy e MaxBy: l’elemento, non il valore (.NET 6+)

MinBy(keySelector) e MaxBy(keySelector) restituiscono l’elemento che ha il valore minimo o massimo della chiave, in un singolo passaggio sulla sequenza:

// .NET 6+ — singolo passaggio, restituisce l'elemento
Product? cheapest      = products.MinBy(p => p.Price);
Product? mostExpensive = products.MaxBy(p => p.Price);

Console.WriteLine($"Più economico: {cheapest?.Name} a €{cheapest?.Price:F2}");
Console.WriteLine($"Più costoso: {mostExpensive?.Name} a €{mostExpensive?.Price:F2}");


Quando più elementi condividono la stessa chiave minima/massima, MinBy/MaxBy restituiscono il primo incontrato, coerentemente con la semantica di OrderBy().First().

Esempio reale: trovare il periodo di vendita con fatturato più alto e la regione con performance peggiore:

// Periodo con il singolo record di fatturato più alto
SalesData? topPeriod = sales.MaxBy(s => s.Revenue);
Console.WriteLine($"Periodo migliore: {topPeriod?.Region} — €{topPeriod?.Revenue:F2}");

// Regione con fatturato totale più basso
var revenueSummary = sales
    .GroupBy(s => s.Region)
    .Select(g => (Region: g.Key, Total: g.Sum(s => s.Revenue)));

(string Region, decimal Total) worstRegion = revenueSummary.MinBy(r => r.Total);
Console.WriteLine($"Regione con fatturato minore: {worstRegion.Region} (€{worstRegion.Total:F2})");


Aggregate: fold personalizzato

Aggregate è l’operatore più generale: accetta un valore seed e una funzione accumulatrice, piegando ogni elemento nel risultato accumulato. È il fold funzionale di LINQ, e può fare tutto ciò che i metodi specializzati fanno — e molto di più:

IEnumerable<Order> orders = GetOrders();

// Concatena gli ID degli ordini come stringa CSV
string orderList = orders.Aggregate(
    string.Empty,
    (acc, order) => string.IsNullOrEmpty(acc)
        ? order.Id.ToString()
        : $"{acc},{order.Id}");

Console.WriteLine($"ID ordini: {orderList}");


Caso pratico: calcolo del fattore di sconto combinato (moltiplicativo):

decimal[] discounts = [0.10m, 0.05m, 0.15m]; // 10%, 5%, 15%

// Il fattore risultante dopo l'applicazione di tutti gli sconti in sequenza
decimal combinedFactor = discounts.Aggregate(
    1.0m,
    (factor, discount) => factor * (1 - discount));

Console.WriteLine($"Fattore combinato: {combinedFactor:P2}"); // es. 72,54%


Aggregate con result selector

La variante a tre argomenti aggiunge una proiezione finale applicata al risultato accumulato dopo aver processato tutti gli elementi. Questo permette di calcolare la media in un singolo passaggio senza dover iterare due volte:

IEnumerable<SalesData> sales = GetSalesData();

decimal averageRevenue = sales.Aggregate(
    seed: (Total: 0m, Count: 0),
    func: (acc, s) => (acc.Total + s.Revenue, acc.Count + 1),
    resultSelector: acc => acc.Count > 0 ? acc.Total / acc.Count : 0m);

Console.WriteLine($"Fatturato medio: €{averageRevenue:F2}");


Aggregazioni multiple in un singolo passaggio

Calcolare più aggregazioni sulla stessa sequenza in modo ingenuo significa iterarla più volte. Per sorgenti costose — query su database, chiamate di rete, lettura di file — questo ha un impatto reale. La soluzione è usare Aggregate con un accumulatore composito che raccoglie tutti i valori contemporaneamente:

public record AggregateSummary(int Count, decimal Sum, decimal Min, decimal Max);

IEnumerable<Order> orders = GetOrders();

AggregateSummary summary = orders.Aggregate(
    new AggregateSummary(0, 0m, decimal.MaxValue, decimal.MinValue),
    (acc, order) => new AggregateSummary(
        acc.Count + 1,
        acc.Sum   + order.Total,
        Math.Min(acc.Min, order.Total),
        Math.Max(acc.Max, order.Total)));

decimal average = summary.Count > 0 ? summary.Sum / summary.Count : 0m;

Console.WriteLine($"Count:   {summary.Count}");
Console.WriteLine($"Somma:   €{summary.Sum:F2}");
Console.WriteLine($"Minimo:  €{summary.Min:F2}");
Console.WriteLine($"Massimo: €{summary.Max:F2}");
Console.WriteLine($"Media:   €{average:F2}");


Un passaggio, quattro valori. Questo pattern è particolarmente utile nei query handler CQRS in cui il risultato richiede più statistiche e la sorgente dati è un database o un servizio esterno.

TryGetNonEnumeratedCount (.NET 6)

Prima di calcolare aggregazioni che richiedono il conteggio, conviene verificare se è disponibile senza enumerare la sequenza:

IEnumerable<Order> orders = GetOrders();

if (orders.TryGetNonEnumeratedCount(out int count))
{
    Console.WriteLine($"Conteggio rapido: {count}");
    // Disponibile senza iterare — usabile per pre-allocazione o logging
}
else
{
    // Fallback: deve enumerare
    count = orders.Count();
}


TryGetNonEnumeratedCount restituisce true per List<T>, array, HashSet<T> e altri tipi che implementano ICollection<T>. Restituisce false per pipeline lazy, risultati di GroupBy e qualsiasi IEnumerable che deve essere valutato per conoscere la propria lunghezza.

Novità .NET 9: CountBy e AggregateBy

.NET 9 ha introdotto due nuovi operatori di aggregazione per chiave che migliorano i pattern basati su GroupBy:

IEnumerable<Order> orders = GetOrders();

// CountBy: conta gli elementi per chiave — più conciso di GroupBy + Count
var countByStatus = orders.CountBy(o => o.Status);
foreach (var (status, count) in countByStatus)
{
    Console.WriteLine($"{status}: {count} ordini");
}

// AggregateBy: aggregazione personalizzata per chiave
var totalByCustomer = orders.AggregateBy(
    keySelector: o => o.CustomerId,
    seed: 0m,
    func: (total, order) => total + order.Total);

foreach (var (customerId, total) in totalByCustomer)
{
    Console.WriteLine($"Cliente {customerId}: €{total:F2}");
}


Per aggregazioni per chiave in .NET 9+, preferire CountBy e AggregateBy rispetto a GroupBy seguito da aggregazione: il codice è più leggibile e spesso più efficiente.

Guida rapida alla scelta dell’operatore

  • Verificare se esiste almeno un elementoAny(predicate)
  • Contare elementiCount() / Count(predicate)
  • Somma di valori proiettatiSum(selector)
  • Media di valori proiettatiAverage(selector)
  • Valore scalare minimo/massimoMin(selector) / Max(selector)
  • Elemento con chiave minima (.NET 6+)MinBy(keySelector)
  • Elemento con chiave massima (.NET 6+)MaxBy(keySelector)
  • Conteggio per chiave (.NET 9+)CountBy(keySelector)
  • Aggregazione personalizzata per chiave (.NET 9+)AggregateBy(keySelector, seed, func)
  • Fold generico con seedAggregate(seed, func)
  • Fold generico con proiezione finaleAggregate(seed, func, resultSelector)

Conclusione

Le operazioni di aggregazione LINQ coprono l’intero spettro delle necessità di riduzione dei dati. Conoscere la differenza tra Any e Count, sapere quando usare MinBy/MaxBy invece di un doppio passaggio, e saper costruire fold multi-valore con Aggregate sono competenze che migliorano sia le performance che la leggibilità del codice. Con l’aggiunta di CountBy e AggregateBy in .NET 9, il toolkit di aggregazione LINQ è oggi più completo che mai: vale la pena tenerlo presente come alternativa ai foreach loop ogni volta che si lavora con trasformazioni di collezioni.

Fonte: LINQ Aggregation in C#: Count, Sum, Min, Max, Average, and Aggregate — Dev Leader

💬 Unisciti alla discussione!


Questo è un blog del Fediverso: puoi trovare quindi questo articolo ovunque con @blog@spcnet.it e ogni commento/risposta apparirà qui sotto.

Se vuoi commentare su LINQ in C#: guida completa alle operazioni di aggregazione (Count, Sum, MinBy, MaxBy, Aggregate), utilizza la discussione sul Forum.
Condividi la tua esperienza, confrontati con altri professionisti e approfondisci i dettagli tecnici nel nostro 👉 forum community