Project to DTOs with Select
When you're building an API endpoint or a report query, fetching the entire entity graph from your database is wasteful. You load columns you don't need, trigger lazy-load surprises, and burn through memory on a 10,000-row result set. Project to DTOs with Select is the technique that stops this: you use LINQ's Select to define exactly which columns you want, and Entity Framework Core translates that directly into a narrow SQL query that fetches only what matters.
Why Select Over Include
Most developers reach for Include when they need related data. It makes sense at first—load the order, include the customer, done. But Include pulls the full rows back into memory, and if you only need the customer's name, you've wasted bandwidth and CPU cycles.
Select, on the other hand, pushes your column list down into the SQL query itself. The database does the joining and projection for you, and you get back only a DTO with the exact fields you need.
Here's the practical difference:
// Include approach: loads full Order + full Customer
var orders = await db.Orders
.Include(o => o.Customer)
.Where(o => o.Status == Status.Paid)
.ToListAsync(ct);
// Select approach: projects only what you need
var orders = await db.Orders
.Where(o => o.Status == Status.Paid)
.Select(o => new OrderRow(
o.Id,
o.Customer.Name,
o.Total
))
.ToListAsync(ct);
The second query generates SQL that looks roughly like this:
SELECT o.Id, c.Name, o.Total
FROM Orders o
INNER JOIN Customers c ON o.CustomerId = c.Id
WHERE o.Status = 1
Much leaner. Much faster at scale.
Select Generates a Narrow SQL SELECT
When you write a projection in Select, EF Core analyzes it and builds a SQL query that matches. It's not magic—it's LINQ-to-SQL translation at work. You tell it which properties you want, and it constructs the column list and joins.
Let's say you have this model:
public class Order
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
public decimal Total { get; set; }
public Status Status { get; set; }
public int CustomerId { get; set; }
public Customer Customer { get; set; }
public ICollection<OrderItem> Items { get; set; }
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}
public record OrderRow(int Id, string CustomerName, decimal Total);
Now this query:
var list = await db.Orders
.Where(o => o.Status == Status.Paid)
.Select(o => new OrderRow(
o.Id,
o.Customer.Name,
o.Total
))
.ToListAsync(ct);
Becomes:
SELECT o.Id, c.Name, o.Total
FROM Orders o
INNER JOIN Customers c ON o.CustomerId = c.Id
WHERE o.Status = 1
Three columns. One join. No bloat. In a high-volume API or report, this difference compounds fast. If you have 50,000 orders and each Order entity loads 20 columns, you're moving 1 million columns unnecessarily. With Select, you move exactly 150,000.
No Tracking, No Lazy-Load Surprises
When you use Select to project into a DTO, the result is no longer an entity that EF Core tracks. It's just plain objects—records, anonymous types, or POCOs. This means:
- No change tracking overhead. EF Core isn't watching for modifications.
- No lazy loading. If you didn't fetch a property in the
Select, it won't magically load later. - No stale-data surprises. You get what you asked for, nothing more.
This is crucial in HTTP request contexts. You load a DTO, serialize it to JSON, send it out, and you're done. There's no chance of accidentally triggering a second database hit because you accessed a navigation property that wasn't eagerly loaded.
// DTO is not tracked
var dto = await db.Orders
.Where(o => o.Id == orderId)
.Select(o => new OrderRow(o.Id, o.Customer.Name, o.Total))
.FirstOrDefaultAsync(ct);
// If you try to access dto.Items, it won't exist.
// If you try to save changes to dto, EF Core won't even know what to do.
// That's the point.
In contrast, if you loaded the full entity and forgot to Include the items collection, accessing order.Items would trigger a lazy-load query—a hidden database call inside your controller. Your N+1 bug is born.
DTOs prevent that entirely. You define the shape you need, fetch it once, and move on.
Works Through Navigations Without Include
Here's a subtle but powerful point: you can traverse navigation properties in a Select projection without ever writing Include. EF Core knows how to join through them because your projection tells it which columns you actually need.
Consider a more complex example:
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public Customer Customer { get; set; }
}
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public int CityId { get; set; }
public City City { get; set; }
}
public class City
{
public int Id { get; set; }
public string Name { get; set; }
public string Country { get; set; }
}
public record OrderWithLocationDto(int OrderId, string CustomerName, string CityName, string Country);
Now this projection chains through three entities:
var list = await db.Orders
.Where(o => o.Status == Status.Paid)
.Select(o => new OrderWithLocationDto(
o.Id,
o.Customer.Name,
o.Customer.City.Name,
o.Customer.City.Country
))
.ToListAsync(ct);
EF Core generates:
SELECT o.Id, c.Name, ct.Name, ct.Country
FROM Orders o
INNER JOIN Customers c ON o.CustomerId = c.Id
INNER JOIN Cities ct ON c.CityId = ct.Id
WHERE o.Status = 1
You never wrote a single Include. EF Core figured out the join path by analyzing what you projected. This is efficient, readable, and safe.
Real-World Scenarios
API list endpoints: A GET /orders endpoint that returns a paginated list with 20 fields per order is slow and over-engineered. Project to a slim DTO with Id, CustomerName, Total, and CreatedDate. Your API response shrinks, client parsing is faster, and the query runs in milliseconds instead of seconds.
Reports and dashboards: A reporting query that needs 50 orders with aggregate data—customer name, order total, item count—can be built entirely with Select and grouping. No full entity load. No N+1 queries. One tight SQL statement.
Search result pages: When you search orders by customer or date, you don't need the full order history or the items collection. Project to a result DTO: Id, CustomerName, Total, OrderDate. Fast, focused, done.
Common Mistakes
- Forgetting to call ToListAsync or FirstOrDefaultAsync. If you don't materialize the query, the
Selecthasn't run yet. You're still holding anIQueryable, and lazy evaluation might bite you if the context is disposed. - Projecting with complex logic inside Select. Avoid method calls that can't be translated to SQL:
.Select(o => new { Name = o.Customer.GetFullName() })will fail. Keep projections to simple property access and arithmetic. - Using Select without a Where clause when you expect filtering. If you don't filter first, you project the entire table. On a large dataset, that's slow. Apply
WherebeforeSelect. - Mixing Select with AsNoTracking. Using
Selectalready returns untracked DTOs, soAsNoTrackingis redundant. It doesn't hurt, but it's unnecessary noise. - Projecting optional navigations without null checks. If
Customercan be null, and you accesso.Customer.Namein the projection, your SQL will still generate an INNER JOIN, dropping orders with no customer. Use a ternary or null-coalescing operator if you want an OUTER JOIN.
Key Takeaways
Selectprojects entities into DTOs, and EF Core translates the projection into a narrow SQL SELECT statement with only the columns you need.- Projections eliminate tracking overhead and lazy-load surprises because DTOs are not managed entities.
- You can traverse navigation properties in a
Selectwithout writingInclude—EF Core generates the joins automatically based on what you project. - Apply
Wherefilters beforeSelectto reduce the dataset early. - Always materialize with
ToListAsync,FirstOrDefaultAsync, orSingleAsyncto execute the query and prevent lazy-eval surprises. - Keep projection logic simple: property access and arithmetic. Avoid method calls that can't translate to SQL.
- DTOs are the right shape for API responses and reports—they're fast, explicit, and safe.






Comments
Loading comments…