Intermediate 45 min.NET 10

Querying, search & pagination

Build efficient, predictable lists without loading the entire database into memory.

BY THE END OF THIS TUTORIAL

Here's what you'll be able to do.

  • Compose a filtered IQueryable before execution
  • Apply stable ordering and bounded pagination
  • Explain when to project, track, and materialize
Before you begin

Complete CRUD and create several tasks. Open TasksController.Index.

Understand the idea

A list page often begins as ToListAsync and grows into a performance problem as data accumulates. The key is to keep work in the database until you have applied the filters, ordering, and limits that define the result.

IQueryable represents a query that a provider can translate. Calling ToListAsync executes it. After that point you have in-memory objects, and later filtering no longer reduces database work or transfer size.

Choose your environment

Open TaskBoard.csproj from the example download. Install the ASP.NET and web development workload and a Visual Studio release supporting .NET 10. Select the TaskBoard launch profile and press F5. Use Solution Explorer to find the files named below.

Download the complete companion project
STEP 01

Compose the query before running it

Start with ownership and AsNoTracking. Add the search predicate only when a meaningful query is present. At this point no rows have been fetched. CountAsync executes a count against the filtered set so pagination describes the same records as the list.

Contains is translated by the database provider. Case sensitivity and matching behavior depend on the database and collation. Avoid promising identical search semantics across SQLite and SQL Server without testing them.

Controller excerpt
var query = db.Tasks.AsNoTracking()
    .Where(t => t.OwnerId == OwnerId);
if (!string.IsNullOrWhiteSpace(q))
    query = query.Where(t => t.Title.Contains(q));
var count = await query.CountAsync(ct);
STEP 02

Bound and order the result

Clamp the requested page to the available range. Order by CreatedUtc and then Id so equal timestamps do not make the ordering ambiguous. Only then apply Skip and Take.

Offset pagination is simple and supports jumping to numbered pages. At very large offsets, keyset pagination can be more efficient. Choose based on the navigation your users need, and measure with representative data before complicating a small feature.

Controller excerpt
const int pageSize = 6;
var totalPages = Math.Max(1, (int)Math.Ceiling(count / (double)pageSize));
page = Math.Clamp(page, 1, totalPages);
var tasks = await query
    .OrderByDescending(t => t.CreatedUtc).ThenBy(t => t.Id)
    .Skip((page - 1) * pageSize).Take(pageSize)
    .ToListAsync(ct);
STEP 03

Preserve the search through navigation

The next-page link carries both page and q. Without the search value, moving to page two would show a different result set. An empty list also deserves a deliberate state: show a helpful message and keep the search form available.

Count and page queries are separate round trips, so concurrent writes can change the set between them. That is generally acceptable for a task list. If you require a consistent snapshot, choose an appropriate transactional strategy and understand its cost.

Razor excerpt
@if (Model.Page < Model.TotalPages)
{
    <a asp-action="Index" asp-route-q="@Model.Query"
       asp-route-page="@(Model.Page + 1)">Next →</a>
}
STEP 04

Project only what a client needs

An API list usually needs fewer fields than an edit form. Select a response shape before materialization to avoid returning owner identifiers or large notes unnecessarily. The sample API caps the response at 100 rows; that bound is part of the example’s behavior.

Use SQL logging during development to inspect executed queries. Watch for repeated per-row queries, accidental early materialization, and indexes that do not match the actual predicates. Do not enable sensitive-data logging in a public environment.

Controller excerpt
var items = await db.Tasks.AsNoTracking()
    .Where(t => t.OwnerId == OwnerId)
    .OrderBy(t => t.Id).Take(100)
    .Select(t => new { t.Id, t.Title, t.IsComplete })
    .ToListAsync(ct);
YOUR TURN

Make it your own.

Create eight tasks and search for a word shared by seven of them. Verify that pagination contains only matching tasks and the second page retains the query.

See the solution & reasoning

The count is calculated after filtering, so seven matches produce two pages with a page size of six. The second page should contain one task. If unrelated tasks appear, the filter was lost from either the query or the next-page link.

When something doesn't work

Repeated or missing rows between page requests can occur when data changes. Stable ordering prevents arbitrary ties but does not freeze the dataset.

If a LINQ expression cannot translate, simplify it or move only the necessary final calculation to memory after a bounded query. Do not fix every translation error by loading the whole table.

Apply owner filtering before both CountAsync and ToListAsync.

CHECK YOUR UNDERSTANDING

Where should Skip and Take be applied?

Where should Skip and Take be applied?

ONE STEP FURTHER

That's another skill in your toolkit.

Your progress is saved in this browser. Sign in to save completion to your account.