Projection with Select
By the end of this lesson
Transform each item into a different shape.
Where decides which items you keep. Select decides what each one turns into. Give it a lambda that takes an item and returns something else, and you get back a sequence of that something else: a string instead of an Employee, a small object with three fields instead of five, a monthly figure instead of an annual one.
The word for this is projection, and it is the operator that most changes how a query reads. A query that ends in Select tells the reader exactly what the caller receives.
// A sequence of strings, not of employees.
IEnumerable<string> names = employees.Select(e => e.Name);
// A calculation, applied to each item.
IEnumerable<decimal> monthlyPay = employees.Select(e => e.AnnualSalary / 12m);
// Several values per item, in a shape that has no name.
var engineeringPay = employees
.Where(e => e.Department == "Engineering")
.Select(e => new { e.Name, e.Role, MonthlySalary = e.AnnualSalary / 12m });
foreach (var row in engineeringPay)
{
Console.WriteLine($"{row.Name} ({row.Role}): {row.MonthlySalary}");
}
// Asha Mehta (Senior Engineer): 150000
// Ravi Iyer (Engineer): 100000
// Neha Kulkarni (Engineering Manager): 200000- Select runs the lambda once per item and returns one result per item. The count never changes: eight employees in, eight results out. Only Where changes how many items there are.
- new { e.Name, e.Role, MonthlySalary = ... } creates an anonymous type: a small read-only type the compiler generates for this one expression. Its property names come from what you wrote, so row.Name and row.MonthlySalary both work and are checked at compile time.
- The type has no name you can write down, which is why the variable is declared var. This is the main situation where var is not a style preference but a requirement.
- That namelessness is also the limit. An anonymous type cannot be a method's return type, a field type, or a parameter type. It is for use inside the method that created it.
- Ordinary C# rules still apply inside the lambda: 12m makes the divisor a decimal, so the division stays in decimal arithmetic rather than converting to double.
When the shape needs to leave the method — returned from a service, sent to a view, serialised into a web response — give it a name. A small record is enough, and the usual name for this kind of type is a DTO, short for data transfer object: a type whose only job is to carry a specific set of fields from one place to another.
Naming the shape buys more than the ability to return it. The name documents the payload, the compiler checks every use of it, and when a field is added you find every place that has to change.
public record PayrollLine(string Name, string Department, decimal MonthlySalary);
public static List<PayrollLine> BuildPayrollLines(List<Employee> employees, string department)
{
return employees
.Where(e => e.Department == department)
.Select(e => new PayrollLine(e.Name, e.Department, e.AnnualSalary / 12m))
.ToList();
}
List<PayrollLine> engineering = BuildPayrollLines(employees, "Engineering");
foreach (PayrollLine line in engineering)
{
Console.WriteLine($"{line.Name}: {line.MonthlySalary}");
}
// Asha Mehta: 150000
// Ravi Iyer: 100000
// Neha Kulkarni: 200000- The only change from the anonymous version is new PayrollLine(...) instead of new { ... }. Everything else is identical, which is why moving from one to the other is cheap once you decide the shape needs a name.
- The return type is List<PayrollLine>, so ToList() is there deliberately: it runs the query and hands the caller a finished list rather than a description of work that still needs doing. The final lesson in this module explains why that distinction matters at a method boundary.
- A record gives value-based equality, so two PayrollLine values with the same three fields compare as equal. That is usually what you want from a carrier type, and it makes testing easier.
- PayrollLine holds a monthly figure that was derived, not stored. Deriving it in the projection keeps the calculation in one place and keeps Employee free of a field that would have to be kept in step.
Flattening a nested collection with SelectMany
// The employees list is unchanged. This adds skills, keyed by employee name.
public record SkillProfile(string EmployeeName, List<string> Skills);
List<SkillProfile> skillProfiles = new List<SkillProfile>
{
new SkillProfile("Asha Mehta", new List<string> { "C#", "SQL", "Azure" }),
new SkillProfile("Ravi Iyer", new List<string> { "C#", "React" }),
new SkillProfile("Divya Rao", new List<string> { "SQL" }),
new SkillProfile("Meera Nair", new List<string>()),
};
// Select gives you a sequence of lists: four items, each one a list.
IEnumerable<List<string>> nested = skillProfiles.Select(p => p.Skills);
Console.WriteLine(nested.Count()); // 4
// SelectMany opens each list and runs them together: six items, each one a string.
IEnumerable<string> allSkills = skillProfiles.SelectMany(p => p.Skills);
Console.WriteLine(allSkills.Count()); // 6
foreach (string skill in allSkills.Distinct())
{
Console.WriteLine(skill);
}
// C#
// SQL
// Azure
// React
// Keep the outer item alongside each inner one.
IEnumerable<string> pairs = skillProfiles
.SelectMany(p => p.Skills, (profile, skill) => $"{profile.EmployeeName}: {skill}");
// Asha Mehta: C#, Asha Mehta: SQL, Asha Mehta: Azure, Ravi Iyer: C#, ...- The lambda you give SelectMany returns a collection, not a single value. SelectMany then produces every element of every returned collection as one flat sequence.
- Compare the two counts. Select preserved the structure and gave four lists. SelectMany discarded the structure and gave six strings. Meera Nair contributed nothing, because her list is empty — items with no children disappear, which is worth remembering when a total comes out lower than the number of people.
- Distinct removes repeats, keeping the first occurrence of each value, which is why C# and SQL appear once and in the order they were first seen.
- The second overload takes two lambdas: one to reach the inner collection, one to build a result from the outer item and the inner item together. Without it the outer item is lost, and "which employee had this skill?" becomes unanswerable.
Summary
- Select turns each item into something else and always returns one result per input item
- An anonymous type is convenient inside a method and cannot be returned from one; a record can
- A DTO is a named shape whose job is to carry a chosen set of fields across a boundary
- SelectMany flattens a collection inside each item into one sequence, and items with empty collections vanish
- A projection should build new values, never modify the items it reads
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
Produce a record called SalaryBand with Name, Department and a MonthlySalary rounded to whole units, for everybody earning more than 1,000,000. Print one line per person.
Tarun Bansal earns 1,100,000. Check what your rounding does to his figure.
Show solution
1,100,000 divided by 12 is 91,666.666..., so this is the one row where rounding is visible. Math.Round with MidpointRounding.AwayFromZero gives 91,667; leaving the default gives the same answer here, but the two rules differ on an exact half, so stating the rule you want is better than relying on the default.
The important judgement is not which rounding rule to use, it is where rounding belongs. Rounding for display is fine. Rounding a figure that will then be paid, or summed twelve times and compared with an annual total, introduces a discrepancy of a few units per employee that finance teams do notice. Keep the unrounded decimal in the data and round at the edge, where it is shown.
public record SalaryBand(string Name, string Department, decimal MonthlySalary);
List<SalaryBand> bands = employees
.Where(e => e.AnnualSalary > 1_000_000m)
.Select(e => new SalaryBand(
e.Name,
e.Department,
Math.Round(e.AnnualSalary / 12m, 0, MidpointRounding.AwayFromZero)))
.ToList();
foreach (SalaryBand band in bands)
{
Console.WriteLine($"{band.Name} ({band.Department}): {band.MonthlySalary}");
}
// Asha Mehta (Engineering): 150000
// Ravi Iyer (Engineering): 100000
// Neha Kulkarni (Engineering): 200000
// Tarun Bansal (Sales): 91667
// Meera Nair (Sales): 225000Challenge
Challenge
Using skillProfiles, report two numbers: how many skill entries exist in total, and how many distinct skills appear across the team.
Then explain, in one sentence, why one of those numbers cannot be obtained with Select alone.
Show solution
The total is 6 and the distinct count is 4. Select cannot produce either directly, because Select preserves one result per employee: it returns four lists, and counting them counts people, not skills.
SelectMany is the step that changes what one item means. After it, the sequence is skills rather than profiles, so Count and Distinct answer questions about skills. Choosing an operator is largely about deciding what one element of the result should be.
IEnumerable<string> allSkills = skillProfiles.SelectMany(p => p.Skills);
int totalEntries = allSkills.Count(); // 6
int distinctSkills = allSkills.Distinct().Count(); // 4
Console.WriteLine($"{totalEntries} entries, {distinctSkills} distinct skills");
// 6 entries, 4 distinct skillsSaved in this browser only.