Featured resource
2026 Tech Forecast
2026 Tech Forecast

1,500+ tech insiders, business leaders, and Pluralsight Authors share their predictions on what’s shifting fastest and how to stay ahead.

Download the forecast
  • Lab
    • Libraries: If you want this lab, consider one of these libraries.
    • Core Tech
Labs

Working Through LINQ Collections in C#

A conference's organizing team has a season's worth of sessions, speakers, and attendee registrations sitting in plain in-memory collections, and the scheduling tool needs a reliable query layer over them before the next release. You are handed the domain types (Session, Speaker, Registration) already wired up, and your job is to fill in the LINQ-based logic that powers the agenda views, room schedules, cross-season attendee comparisons, attendance reports, and a lazy tag-streaming feature the frontend team is waiting on.

Lab platform
Lab Info
Level
Intermediate
Last updated
Nov 11, 2026
Duration
50m

Contact sales

By clicking submit, you agree to our Privacy Policy and Terms of Use, and consent to receive marketing emails from Pluralsight.
Table of Contents
  1. Challenge

    Step 1: Tour the session, speaker, and registration data

    Welcome to the conference query layer

    The conference runs a season of conference sessions, and the organizing team's scheduling tool needs a reliable way to search, filter, join, and summarize its data. Right now that data — Session, Speaker, and Registration — sits in plain in-memory collections, waiting for a query layer to sit on top of it. That query layer is what you will build, one LINQ technique at a time. ## The domain types

    All of the domain types live in src/Models.cs:

    • Speaker is a record with Id and Name.
    • Session is a mutable class with Id, Title, Track, Room, StartsAt, SpeakerId, Tags, and AttendeeCount. It is mutable on purpose: later you will write query results back onto it.
    • Registration links an Attendee to a session through its SessionId.
    • Attendee is a record with Name and Email. Because it is a record, two attendees are equal only when both values match exactly, which matters when you compare rosters in Step 3.

    The same file also holds the small result types your queries will produce, such as SessionBriefing and AttendanceSummary, plus AttendeeEmailComparer.

    The top of src/Program.cs seeds sample data: four sessions, three speakers, and separate spring and fall registration lists, so the same person can show up in both seasons. The console program runs every query you write against that data, and you can run it at any point with dotnet run --project src/Lab.csproj. ## How they relate

    A Session.SpeakerId points at a Speaker.Id — that relationship is what a Join will exploit in Step 4. A Registration.SessionId points at a Session.Id, and grouping registrations by that key is how attendance gets counted. Two sessions can also share tags in Session.Tags, which is what lets you flatten and stream tag data later without ever touching the speaker or registration collections. ## What's still missing

    The agenda views need session lookups and projections. The room schedule needs ordering, filtering, safe single-element lookups and a morning and afternoon split, and the organizers want to know who attended both seasons. The attendance report needs joins, grouping, and a way to push computed totals back onto Session objects. The frontend team is also waiting on a lazy, page-friendly tag stream. Every one of those gaps is a task in the steps ahead — starting with the most basic speaker lookup.

    info> If you get stuck, you can refer to the provided solution code for each task, available in the solution folder.

  2. Challenge

    Step 2: Build the foundational speaker and session projections

    Query syntax vs. method syntax

    LINQ offers two equivalent ways to write the same query: query syntax, which reads like SQL (from x in xs where ... select ...), and method syntax, which chains extension methods (xs.Where(...).Select(...)). They compile to the same calls, but method syntax composes more easily when a pipeline grows past one or two operators — which is why the rest of the query layer in this lab is written that way. src/SpeakerQueries.cs still has one lookup written the old way. ## Projecting with Select

    Select transforms every element of a sequence into something else, one-to-one, without changing how many elements come out. It is the tool for shrinking a Session down to only the fields a particular view needs, or for reshaping data into a different type entirely. src/SessionQueries.cs needs exactly that: a lightweight SessionBriefing for each Session. ## Flattening with SelectMany

    Select keeps a one-to-one shape, but a Session holds a whole list of tags — a one-to-many relationship. SelectMany is the operator for that case: it projects each element to a sequence, then flattens all those sequences into one. Used on Session.Tags, it turns "N sessions, each with a list of tags" into one flat stream of session-and-tag pairs, which is what the tag dashboard actually consumes.

  3. Challenge

    Step 3: Order, filter, partition, and compare session data

    Ordering and filtering together

    Where narrows a sequence down to matching elements; OrderBy and its follow-up ThenBy control the order those elements come out in. The two compose cleanly: filter first so you never sort data you are about to discard, then order, then break ties with ThenBy. The track agenda in src/SessionQueries.cs needs exactly that shape. ## Safe single-element lookups

    First() and Single() throw when nothing matches, which is rarely what a query layer wants to expose to a caller. FirstOrDefault() and SingleOrDefault() return a fallback — usually null for reference types — instead, letting the caller decide what an absent result means. Combined with ordering, FirstOrDefault() is how you safely grab "the earliest one, if there is one" instead of crashing on an empty room. ## Set operations and custom equality

    Except, Intersect, and Union treat sequences like mathematical sets, but they need to know when two elements count as "the same one." By default that is Equals, which for a record compares every field — too strict when two Attendee values differ only by email casing. Passing an IEqualityComparer<T> overrides that comparison, so Intersect can recognize "[email protected]" and "[email protected]" as the same attendee across two events' rosters. ## Partitioning with a predicate

    Sometimes you need both halves of a filter: the sessions that match and the ones that don't. Calling Where twice works, but it reads the source twice. ToLookup with a bool key reads it once and hands back both groups: everything under true and everything under false. Each group keeps the source order, and asking for a key with no elements returns an empty sequence instead of throwing. The printed day schedule needs the catalog split into a morning block and an afternoon block.

  4. Challenge

    Step 4: Join, group, and update conference records

    Joining two collections

    Join combines two sequences on a shared key, much like a SQL inner join: for every pair whose keys match, it produces one output element, and pairs with no match on either side are dropped. Sessions and speakers are linked by SpeakerId / Id, so Join is how src/AgendaQueries.cs turns two separate collections into one list of speaker-enriched agenda entries. ## Grouping and aggregating

    GroupBy partitions a sequence into groups sharing a key, and each group is itself a sequence you can aggregate with Count(), Sum(), or Average(). Grouping Registration values by SessionId is how the number of attendees per session gets calculated — one group per session, one aggregate per group. ## Writing query results back onto source objects

    LINQ queries are normally read-only: they describe new sequences without changing what they read from. But Session is a mutable class specifically so a computed summary can be written back onto it, and that mutation always happens in an ordinary foreach, never inside Select — mixing side effects into a projection makes deferred execution unpredictable, which is exactly what the next step examines.

  5. Challenge

    Step 5: Trace deferred execution and stream results lazily

    Deferred vs. immediate execution

    Most LINQ operators, including Where and Select, build a query object without running it. The query only executes when something enumerates it — a foreach, a ToList(), an Any(). Until then, it re-reads the source collection every time it is enumerated, which means changes to that source made after the query was declared, but before it runs, are still visible. ToList() and ToArray() break that: they run the query immediately and freeze a snapshot. ## Writing your own iterator

    Built-in operators like Distinct are themselves implemented as custom iterators, and you can write the same kind: a method that returns IEnumerable<T> and uses yield return inside its body produces values lazily, one at a time, only as the caller enumerates them. Tracking state — like which tags have already been seen — across those yield return calls with a local HashSet<T> is what lets a hand-written iterator stay both lazy and duplicate-free. ## Chaining a custom iterator into a pipeline

    A method you write with yield return is a perfectly ordinary IEnumerable<T> to everything downstream, so built-in operators like Skip and Take chain onto it exactly as they would onto Distinct() or Where(). Because every link in the chain stays lazy, asking for one page of tags only pulls as many values through your iterator as that page needs — not the whole tag catalog.

  6. Challenge

    Step 6: Watch the conference query layer at work

    See the query layer work on real data

    Every task is done and graded. Now watch the pieces work together on one shared data set in src/Program.cs: four sessions, three speakers and two seasons of registrations. You'll run the program once, then make three small edits that make deferred execution, lazy streaming and custom equality visible. ### Run the whole query layer

    In the Terminal tab, run:

    dotnet run --project src/Lab.csproj
    

    Each section is labeled with the task it exercises. Look for these lines:

    • Shape versus order: the briefings list Advanced Deferred Execution | Core | A1 | 11:00 AM before Building Iterators | Advanced | B2 | 10:00 AM. Your Select from Task 2.2 changes each element's shape but never the order. The Core agenda just below is in time order only because Task 3.1 asks for it with OrderBy and ThenBy.
    • One pass, two blocks: Morning (3) and Afternoon (1). A single ToLookup call from Task 3.4 built both, and Grouping and Joining Data at 1:00 PM is the only afternoon session.
    • Joined and written back: Grace Hopper appears twice in the enriched agenda, because your Task 4.1 join produces one row per matching pair. Further down, Intro to LINQ: AttendeeCount = 2 is read straight off the Session objects, so your Task 4.3 write-back changed the source data, not a copy. ### Watch deferred execution happen

    In src/Program.cs, find the Task 5.1 section and this line:

    var upcoming = SchedulePipeline.GetUpcomingSessionsQuery(sessions, cutoff);
    

    Directly below it, add:

    sessions[2].StartsAt = new DateTime(2024, 9, 10, 15, 0, 0);
    

    Run the program again. The Task 5.1 list grows from two sessions to three, with 3:00 PM | Building Iterators in the middle. You moved that session after the query was created, and it still shows up, because your query reads the data only when the foreach enumerates it.

    Now add .ToList() to the end of the GetUpcomingSessionsQuery(...) call and run once more. The list drops back to 11:00 AM | Advanced Deferred Execution and 1:00 PM | Grouping and Joining Data, because ToList() ran the query immediately, before the change. Remove both edits. ### Watch your iterator stream

    In src/SchedulePipeline.cs, inside StreamDistinctTags, add this line directly above your yield return (use your own loop variable if it isn't named tag):

    Console.WriteLine($"  (yielding {tag})");
    

    Run the program. Task 5.2 logs all five tags, but Task 5.3 logs only (yielding linq), (yielding csharp) and (yielding performance). Skip(1) pulled one tag and Take(2) pulled two more, then stopped asking, so iterators and data were never produced at all. That is what lazy means, and it works only because your Task 5.2 method uses yield return instead of building a list. Remove the line. ### Watch the equality rule decide who matches

    Pat registered as [email protected] in spring and [email protected] in fall. In src/RegistrationQueries.cs, remove the AttendeeEmailComparer argument from your Intersect call and run the program. Task 3.3 now reports Attendees at both events: 0, because Attendee is a record and its default equality treats the two spellings as different people.

    Put the comparer back and run once more. Pat Nguyen <[email protected]> returns, in the spring spelling, because Intersect keeps elements from the first sequence.

    You now have a tested LINQ query layer for the conference: lookups, projections, ordering, partitioning, set comparison, joins and write-backs, plus a lazy tag stream you wrote yourself.

About the author

Pluralsight’s AI authoring technology is designed to accelerate the creation of hands-on, technical learning experiences. Serving as a first-pass content generator, it produces structured lab drafts aligned to learning objectives defined by Pluralsight’s Curriculum team. Each lab is then enhanced by our Content team, who configure the environments, refine instructions, and conduct rigorous technical and quality reviews. The result is a collaboration between artificial intelligence and human expertise, where AI supports scale and efficiency, and Pluralsight experts ensure accuracy, relevance, and instructional quality, helping learners build practical skills with confidence.

Real skill practice before real-world application

Hands-on Labs are real environments created by industry experts to help you learn. These environments help you gain knowledge and experience, practice without compromising your system, test without risk, destroy without fear, and let you learn from your mistakes. Hands-on Labs: practice your skills before delivering in the real world.

Learn by doing

Engage hands-on with the tools and technologies you’re learning. You pick the skill, we provide the credentials and environment.

Follow your guide

All labs have detailed instructions and objectives, guiding you through the learning process and ensuring you understand every step.

Turn time into mastery

On average, you retain 75% more of your learning if you take time to practice. Hands-on labs set you up for success to make those skills stick.

Get started with Pluralsight