Yeah, HashSet<T> is definitely another one to prefer over ToList often enough. There is a ToHashSet finally in Enumerable extensions now so maybe more developers will discover it now. (It was finally added somewhere around .NET Core 2, as I recall.) Also, a quick shoutout to System.Collections.Generic.Immutable for having great tools like ImmutableHashSet<T> and ToImmutableHashSet for when mutability distracts from your algorithm/data structure needs.

ILookup<K, V> is basically the "in memory join and group by" tool. If a query sort of naturally ends in a GroupBy because you want things sorted/grouped by an enum field or a foreign key field, but you still want all of the items in the groupings rather than just aggregate stats on the groupings, that's often where you want ToLookup instead of GroupBy to reify your query.

In general, Enumerable.Join and other in memory "join" operations (as opposed to the IQueryable's Join which more often than not gets sent to a highly tuned query planner in an SQL server somewhere) are often O(n^2) even at best. A lot of developers do notice this, that joins are expensive when performed in memory, but simply rewrite them by hand as nested foreach loops which still has the same O(n^2) worst cases, but it's hand-written so developers feel more comfortable with how slow it is. On the other side, in C# a lot of developers tend to naturally write these kinds of "in memory join operations" as nested foreach loops anyway (or a foreach loop with a big Enumerable.Where query inside acting as a second foreach loop on the other data source), just because they've always done it that way, and might not even think of them as "an in memory join" much less think to replace hand-written loops with something more LINQ-like anyway. ToLookup is generally the handiest tool to replace both sorts of O(n^2) "joins" the Enumerable.Join operations that do their best but don't know your model domain and don't have the query planner and indexes of an SQL database to fallback on, and the hand-written nested loops: you "index" the "right side" of your join (the inner foreach/if or Enumerable.Where in hand-written loops) on your join key with ToLookup.

As an example, perhaps: you've got a list of items in a shopping cart, which is only in memory in session data (so "naturally reified"), and a table of coupon/discount "rules" that apply primarily based on "item category ID" (with a Category FK table in between Item and CouponRule in your normalized DB structure). The most naive way of writing that would be something like:

foreach (var item in shoppingCart) {
var couponRules = db.CouponRules.AsQueryable()
.Where(rule => rule.CategoryId == item.CategoryId);

foreach (var rule in couponRules) {
if (RulesService.DoesRuleApply(item, couponRule)) {
RulesService.Apply(item, couponRule);
}
}
}

Written that naively that's at least one database query for every item in the shopping cart, which is obviously not optimal so a developer's first instinct is going to be to reify that query somewhere. The simplest, equally naive reification is often this (though often by this point "hidden" as a cache inside "RulesService"):

var allCouponRules = await db.CouponRules.AsQueryable()
.ToListAsync();
foreach (var item in shoppingCart) {
var couponRules = allCouponRules
.Where(rule => rule.CategoryId == item.CategoryId);

foreach (var rule in couponRules) {
if (RulesService.DoesRuleApply(item, couponRule)) {
RulesService.Apply(item, couponRule);
}
}
}

(Again, it's maybe more obvious written this way, but often at this point what I find is that allCouponRules cache moves inside RulesService and that Where and foreach get "hidden" inside RulesService at this point, making it even tougher to optimize this situation many times in practice because you don't see all the loops in one place by that point.)

To find possibly applicable coupon rules this does an O(n) search over all rules for every single item, even though we already know we primarily only care about the subset that joins on Category ID. This is where handwritten O(n^2) worst cases often hide. If you grow to have a lot of item categories with a lot of coupon rules that might be a huge list in memory. This is where ToLookup most shines as your easily cacheable reification to speed up exactly these sorts of joins.

var couponRulesByCategoryId = await db.CouponRules.AsQueryable()
.ToLookupAsync(rule => rule.CategoryId);

foreach (var item in shoppingCart) {
var couponRules = couponRulesByCategoryId[item.CategoryId];
/* most implementations of ILookup return Enumerable.Empty<T>() for missing keys so you don't even need to guard this lookup with a ContainsKey check */

foreach (var rule in couponRules) {
if (RulesService.DoesRuleApply(item, couponRule)) {
RulesService.Apply(item, couponRule);
}
}
}

That couponRulesByCategoryId lookup is itself O(log n) like most Dictionary/Set lookups, bringing the overall worst case here more in line with O(n log n) worst cases than O(n^2).

I find so many patterns like this where cached ToLookup "indexes" in memory/caches greatly speed up operations. There's still further things that might be optimized with ToLookup to explore even in this example: you could "index" the "left side" here too (shoppingCart.ToLookup(item => item.CategoryCode) and maybe the "RulesService" can change to take IEnumerable<Item> instead of item at-a-time. Maybe lurking in DoesRuleApply() filter is a secondary key to "index" on, perhaps as a ToLookup to a tuple key instead (in turn saving even more rules that don't need to be checked individually and can be ignored as a group). Once you start finding these sorts of patterns I feel like you start to see them everywhere in C# and that's a big part of why I think ToLookup is an unsung hero of C# optimization work.

I do wish some version of the mutable MultiValueDictionary<K, V> which implements ILookup<K, V> finally makes into System.Collections.Generic properly instead of being an "experimental" or "labs" nuget package away. You can fake it with Dictionary<K, List<V>>, but that's a lot more awkward to work with (including a lot of ContainsKey/Add(new List) dances) and doesn't naturally implement ILookup<K, V>. I think an obviously mutable version would make a lot of developers more used to using ToLookup even if there are very good reasons that the default implementations are all immutable.