With Library now bringing together the entire catalog and every member in collections, the natural question is: how do you answer questions like "which books are available?", "how many items are there by each author?", or "which loans have gone more than two weeks without being returned?" With nothing but a foreach, every question would require writing a manual loop with auxiliary variables. LINQ (Language Integrated Query) solves this with a set of operators — filter, transform, sort, group — that combine expressively and work on any IEnumerable<T> (the interface that closed the previous lesson). This lesson teaches you to query BiblioTech's catalog and loans with LINQ, leaving manual loops behind for this kind of task.

Contents

  1. What LINQ is and what problem it solves
  2. Method syntax: Where, Select, OrderBy
  3. Grouping results: GroupBy
  4. Operators that return a single value: FirstOrDefault, Any, Count
  5. Query syntax: from...where...select
  6. Deferred execution versus immediate execution
  7. Querying BiblioTech's catalog and loans

  1. What LINQ is and what problem it solves

LINQ adds, to any collection implementing IEnumerable<T> (List<T>, arrays, Dictionary<TKey, TValue>.Values, and many more), a set of extension methods for querying it declaratively: instead of describing how to traverse it step by step with a foreach, you describe what result you want, and LINQ takes care of the traversal for you.

// Without LINQ: describing "how" to traverse, with a foreach and an auxiliary list
List<LibraryItem> available = new List<LibraryItem>();
foreach (LibraryItem item in library.Catalog)
{
    if (item.Available)
    {
        available.Add(item);
    }
}

// With LINQ: describing "what" result is wanted
List<LibraryItem> availableLinq = library.Catalog.Where(i => i.Available).ToList();

Both fragments produce the same result, but the second directly expresses the intent ("the items where Available is true"), without the intermediate mechanics of the loop and the auxiliary list.

  1. Method syntax: Where, Select, OrderBy

The most commonly used LINQ operators, in their usual form (method syntax, chaining calls with .):

Operator What it does Example
Where Filters elements by a condition (Func<T, bool>) catalog.Where(i => i.Available)
Select Transforms each element into something else (projection) catalog.Select(i => i.Title)
OrderBy Sorts ascending by a key catalog.OrderBy(i => i.Title)
OrderByDescending Sorts descending catalog.OrderByDescending(i => i.Title)
List<LibraryItem> available = library.Catalog
    .Where(i => i.Available)
    .OrderBy(i => i.Title)
    .ToList();

foreach (LibraryItem item in available)
{
    Console.WriteLine(item.Title);
}

This chain reads left to right like a pipeline: "take the catalog, keep only the available ones, sort them by title, and turn it into a list." Select transforms each element, useful when you don't need the whole object but just one of its pieces of data:

List<string> titles = library.Catalog.Select(i => i.Title).ToList();
// ["Hopscotch", "Ficciones", "National Geographic"]

  1. Grouping results: GroupBy

GroupBy splits a collection's elements into groups by a key, something that with a manual foreach would require a hand-built Dictionary<TKey, List<T>>:

var byAuthor = library.Catalog.GroupBy(i => i.Author);

foreach (var group in byAuthor)
{
    Console.WriteLine($"{group.Key}: {group.Count()} item(s)");
    foreach (LibraryItem item in group)
    {
        Console.WriteLine($"  - {item.Title}");
    }
}

Each group is, at the same time, the key (group.Key, the author) and a collection of all the elements with that key, over which you can iterate again or apply more LINQ. var is used here because the exact type GroupBy returns (IEnumerable<IGrouping<string, LibraryItem>>) is long to write and rarely needs to be named explicitly.

  1. Operators that return a single value: FirstOrDefault, Any, Count

Not every query returns a collection; some answer with a single value:

Operator What it returns
FirstOrDefault(condition) The first element satisfying the condition, or default (usually null) if none does
First(condition) Same, but throws an exception if no element satisfies it
Any(condition) true if at least one element satisfies the condition
Count(condition) How many elements satisfy the condition
Count() (no argument) The total number of elements
LibraryItem? firstAvailable = library.Catalog.FirstOrDefault(i => i.Available);

bool hasMagazines = library.Catalog.Any(i => i is Magazine);

int totalBooks = library.Catalog.Count(i => i is Book);

Console.WriteLine(firstAvailable?.Title ?? "None available");
Console.WriteLine(hasMagazines); // True
Console.WriteLine(totalBooks);   // 2

FirstOrDefault is preferable to First whenever "finding nothing" is a normal, expected situation (as here); reserve First for when the absence of a result would actually represent a bug in the program's logic.

  1. Query syntax: from...where...select

Besides method syntax, C# offers a query syntax, closer to SQL, which the compiler translates internally to the same methods (Where, Select...) seen above:

var availableQuery =
    from item in library.Catalog
    where item.Available
    orderby item.Title
    select item;

foreach (LibraryItem item in availableQuery)
{
    Console.WriteLine(item.Title);
}

This query is exactly equivalent to library.Catalog.Where(i => i.Available).OrderBy(i => i.Title): two different ways of writing the same operation. In practice, method syntax is the more common one in modern C# code (it fits better with long chains and with operators that have no direct equivalent in query syntax, such as Count or Any); query syntax is used mostly when several sources are combined (join), or when the team prefers it for readability. It's enough to recognize both and know they're interchangeable.

  1. Deferred execution versus immediate execution

An important detail, easy to overlook: most LINQ operators (Where, Select, OrderBy...) don't run at the moment they're written, but when the result is traversed (with foreach, or by calling ToList()/ToArray()/Count()). This is called deferred execution:

var query = library.Catalog.Where(i => i.Available); // nothing has been evaluated yet

library.AddItem(new Book("The Aleph", "Jorge Luis Borges", "978-84-376-0497-8"));

foreach (LibraryItem item in query) // the filter runs HERE, over the already-updated catalog
{
    Console.WriteLine(item.Title); // "The Aleph" is included too, even though it was added after "query" was defined
}

Operators like ToList(), ToArray(), Count(), or FirstOrDefault(), on the other hand, force immediate execution: the result is computed right then and stays fixed, unaffected by later changes to the source collection. For everyday use, this practical rule is enough to remember: if you're going to traverse the same result more than once, or need to "freeze" it at a specific moment, add .ToList() at the end of the query.

  1. Querying BiblioTech's catalog and loans

With everything above, here's how the questions posed at the start of the lesson about Library get answered:

// Which books are available, sorted by title?
List<LibraryItem> availableBooks = library.Catalog
    .Where(i => i is Book && i.Available)
    .OrderBy(i => i.Title)
    .ToList();

// How many items are there by each author?
var itemsByAuthor = library.Catalog
    .GroupBy(i => i.Author)
    .Select(g => new { Author = g.Key, Count = g.Count() });

foreach (var entry in itemsByAuthor)
{
    Console.WriteLine($"{entry.Author}: {entry.Count}");
}

// Which loans have gone more than 14 days without being returned (overdue)?
const int LoanLimitDays = 14;

List<Loan> overdueLoans = library.Loans
    .Where(l => l.ReturnDate == null && (DateTime.Now - l.LoanDate).Days > LoanLimitDays)
    .ToList();

foreach (Loan loan in overdueLoans)
{
    Console.WriteLine($"Overdue: '{loan.Book.Title}' lent to {loan.Member.Name} on {loan.LoanDate:yyyy-MM-dd}");
}

itemsByAuthor uses a feature you may not have seen yet: new { Author = g.Key, Count = g.Count() } creates an anonymous type, a throwaway object with only the properties you need to display, with no class or separate record to declare — useful precisely for intermediate results of a LINQ query that don't need a named type anywhere else in the program.

The overdue loans query combines two conditions with &&: that it hasn't been returned yet (ReturnDate == null) and that more days have passed than the loan limit since LoanDate. This is exactly the kind of business question ("which loans need an overdue notice?") that LINQ lets you express in a few lines, with no manual loop at all.

Common Mistakes and Tips

  • Forgetting .ToList() (or similar) when immediate execution is needed: if you modify the source collection between defining the query and traversing it, the result can change unexpectedly due to deferred execution; use .ToList() if you need to "fix" the result at a specific moment.
  • Chaining too many operators into one unreadable line: although technically valid, a very long chain of .Where().Select().OrderBy().GroupBy()... on a single line is hard to read; split the query across several lines, one operation per line, as in this lesson's examples.
  • Using First when "not found" is a normal situation: First throws an InvalidOperationException if no element satisfies the condition; use FirstOrDefault and check is not null (recalling the Pattern Matching lesson) unless the absence of a result is, in fact, a program error.
  • Confusing Count (LINQ operator) with Count (property of List<T>): list.Count (no parentheses) is a fast property of List<T>; list.Count(condition) (with parentheses and a predicate) is the LINQ operator, which traverses the collection to count how many elements satisfy the condition. Both coexist with no conflict because they have different signatures.
  • Tip: for simple filtering, transforming, and sorting tasks, method syntax is more common and easier to debug step by step (you can comment out one line of the chain to see the intermediate result); reserve query syntax for when you combine several sources with join, or when the team explicitly prefers it.

Exercises

  1. On library.Catalog, use method syntax to get a List<string> with the titles of all Available items, sorted alphabetically.

  2. On library.Catalog, use GroupBy to group items by author and show, for each author, how many items they have in the catalog.

  3. On library.Loans, write a query that returns unreturned loans (ReturnDate == null) that have gone more than 14 days since LoanDate. Test it with at least one loan that satisfies the condition and one that doesn't.

Solutions

List<string> availableTitles = library.Catalog
    .Where(i => i.Available)
    .OrderBy(i => i.Title)
    .Select(i => i.Title)
    .ToList();

foreach (string title in availableTitles)
{
    Console.WriteLine(title);
}
var byAuthor = library.Catalog.GroupBy(i => i.Author);

foreach (var group in byAuthor)
{
    Console.WriteLine($"{group.Key}: {group.Count()} item(s)");
}
const int LoanLimitDays = 14;

List<Loan> overdue = library.Loans
    .Where(l => l.ReturnDate == null && (DateTime.Now - l.LoanDate).Days > LoanLimitDays)
    .ToList();

foreach (Loan loan in overdue)
{
    Console.WriteLine($"Overdue: '{loan.Book.Title}' ({loan.Member.Name})");
}

To check the result, you could create a Loan with a manually simulated, older LoanDate (recalling that, in the current model, LoanDate is automatically set to DateTime.Now in the constructor), or simply reason about freshly created loans, which will never show up as overdue at the same instant they run.

Conclusion

In this lesson you've learned to query collections with LINQ: filtering with Where, transforming with Select, sorting with OrderBy, grouping with GroupBy, and getting single values with FirstOrDefault, Any, and Count; you've also seen the alternative query syntax and the difference between deferred and immediate execution. Library.Catalog and Library.Loans can now be queried with the same expressiveness as a database, without having written a single line of SQL.

That comparison is, in fact, no accident: in Module 5 (Working with Data), Entity Framework will use LINQ as its main query language against a real database, so everything learned here will carry over directly. Before getting there, one last piece remains in this module: everything seen so far has been synchronous code, which blocks execution while it completes. The module's last lesson, Asynchronous Programming, introduces async/await and a first simulated version of LendBookAsync, preparing Library for the real I/O operations (files, databases) coming in the next module.

© Copyright 2026. All rights reserved