Projections, Pagination & Sorting
Loading full entities and unbounded result sets is wasteful by default — projections fix over-fetching in shape, pagination fixes it in size, and Page/Slice/Stream trade off cost against what information you actually need.
Learning objectives
- Explain the performance difference between an interface projection and loading a full entity.
- Choose correctly between Page, Slice, and Stream based on an endpoint's actual requirements.
- Explain why unbounded pagination on a public endpoint is both a performance and a resource-protection risk.
Loading full entities for every query is often wasteful — and no real application should ever hand a user interface an unbounded result set. This chapter covers avoiding over-fetching in two dimensions: which FIELDS you load, and how MANY rows you load at once.
📖 Story
Imagine a restaurant menu with a thousand items. A customer flipping through it doesn't need the chef's full recipe, sourcing notes, and nutritional breakdown for every single dish — they need the name, a short description, and the price, a manageable page at a time.
Here's what loading the "full recipe binder" looks like in code, for a product listing page that only ever displays three fields:
List<Product> products = productRepository.findByCategory("electronics"); // Loads EVERY mapped field of EVERY Product — including lazy associations // that might trigger their own queries — just to show name/price/thumbnail.
Here's the "just the menu" version:
public interface ProductSummary { String getName(); BigDecimal getPrice(); String getThumbnailUrl(); } public interface ProductRepository extends JpaRepository<Product, Long> { List<ProductSummary> findByCategory(String category); // returns ONLY these 3 fields }
Same method name, same query — but Spring Data generates a query selecting ONLY name, price, and thumbnail_url, with no full entity ever constructed at all.
Projection — a query result shaped to include only specific fields, not a full entity. DTO projection — a projection mapped into a plain class you define, decoupled from your entity. Page — a Spring Data result wrapper including total element/page counts (needs an extra COUNT query). Slice — a lighter alternative to Page that only knows whether a next page exists, skipping the count query. Stream — consuming a large result set incrementally, without loading it all into memory.
Let's extend this chapter's ProductSummary example to cover pagination too — because loading three fields instead of thirty doesn't help if you're still loading a million rows at once.
DTO projections — for more control than an interface gives you
public interface ProductRepository extends JpaRepository<Product, Long> { @Query("SELECT new com.shop.ProductSummaryDto(p.name, p.price) FROM Product p WHERE p.category = :cat") List<ProductSummaryDto> findSummariesByCategory(@Param("cat") String category); }
This "constructor expression" maps query results directly into a DTO class you define yourself — useful when you need computed fields or a shape the interface-projection style can't quite express.
Page vs. Slice — the pagination half of this chapter's problem
// Page: also runs a separate COUNT query, so you know "page 3 of 47" Page<ProductSummary> page = productRepository.findByCategory("electronics", PageRequest.of(0, 20)); System.out.println(page.getTotalElements()); // works — but cost a second query // Slice: skips the count query entirely — cheaper, but no total available Slice<ProductSummary> slice = productRepository.findByCategory("electronics", PageRequest.of(0, 20)); System.out.println(slice.hasNext()); // true/false — no total count needed or paid for
If your UI genuinely shows "page 3 of 47," you need Page. If it's an infinite-scroll feed that only ever needs "load more," Slice gets you the same behavior for less cost.
Stream — for a batch job processing millions of rows
try (Stream<Product> stream = productRepository.streamAllByCategory("electronics")) { stream.forEach(product -> processOneProduct(product)); // Rows are fetched and processed incrementally — never all held in memory at once. }
For interface projections like this chapter's ProductSummary, Spring Data generates a JPQL query selecting only the fields your projection interface's getters correspond to, then at runtime builds a dynamic proxy implementing your interface, backed directly by that query result row — no full entity is ever constructed. Page's implicit count query is a completely separate SQL statement (SELECT COUNT(*) FROM ... WHERE <same conditions>), executed alongside your main paginated query — this is exactly why Page costs strictly more than Slice for the same query.
- This chapter's product listing page is exactly the standard e-commerce/marketplace case for a projection — avoiding the cost of hydrating full entities the UI never fully uses.
- Infinite-scroll feeds (social-media style) prefer
SliceoverPagespecifically because they only ever need "is there more to load," never a total count. - A nightly batch export processing millions of rows uses
Stream<T>to keep memory bounded, rather than attempting to load the entire result set at once.
- Use a projection, like this chapter's
ProductSummary, for any read path where the consumer needs only a subset of fields — an easy, low-risk win with no downside for read-only display data. - Prefer
SliceoverPagewhenever you don't actually need a total count displayed — the count query's cost is pure waste if nothing renders it. - Use
Stream(with try-with-resources, as shown above) for any batch job processing many rows, to keep memory bounded. - Always paginate any endpoint that could return an unbounded number of rows.
⚠️ Why this keeps happening
Projections, Page, Slice, and Stream all look similar at the method-signature level — different return types — so it's easy to default to whichever one an IDE autocompletes, rather than deliberately matching the tool to the actual access pattern.
- Always using
Pageeven when the total count is never displayed anywhere, paying for an unnecessary count query on every single request. - Loading full entities for a pure display list, like this chapter's opening "wrong way" example, that only ever shows 2-3 fields.
- Forgetting to close a
Stream<T>(skipping try-with-resources), leaving the underlying database cursor open longer than intended.
A projection skips full entity hydration entirely — for a wide entity with many columns and lazy associations, this is often a substantial, easy performance win on read-heavy list views, exactly like this chapter's product listing. Slice avoiding the count query is a direct, measurable win over Page for the same paginated query.
No projection-specific risk directly, but a DTO projection is a natural, deliberate point to enforce which fields ever reach a given API caller — a good habit for preventing accidental over-exposure of sensitive entity fields that a full entity serialization might otherwise leak.
Track whether count queries (from Page-based endpoints) show up disproportionately in slow-query logs relative to their paired paginated query — a large table's count query can, counter-intuitively, sometimes be slower than the paginated data query itself.
Set a maximum enforced page size for any public-facing paginated endpoint — an unbounded client-requested page size defeats the entire purpose of pagination as a resource-protection mechanism.
- Build this chapter's
ProductSummaryinterface projection, and confirm (via SQL logging) that the generated query selects only those specific columns. - Implement the same paginated query using both
PageandSlice, and compare how many SQL statements each executes. - Process a large table using
Stream<T>with a bounded fetch size, confirming the full result set is never held in memory at once.
✓ Quick recap
- Projections (interface-based, like
ProductSummary, or DTO-based) select only needed fields, skipping full entity hydration. Pageincludes a separate count query;Sliceskips it, cheaper when a total count isn't needed;Streamprocesses large result sets incrementally.- Always paginate any endpoint whose result set size isn't inherently bounded.
Want a visual for this concept?
Generate a diagram tailored to “Projections, Pagination & Sorting” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →