PostgreSQL and Prisma
Keeping PostgreSQL List Queries Fast as Data Grows
A list endpoint needs a clear ordering contract, realistic query plans, bounded results, and a pagination strategy that still works beyond the first page.
A list endpoint often becomes a performance problem gradually. The first version returns a few records quickly, the interface adds filters, and the database accumulates enough history that the original query no longer has a predictable cost. By the time users notice, several screens may depend on the same loosely defined behavior.
The engineering opportunity is to give list queries an explicit contract before growth turns accidental behavior into a compatibility requirement. Define which records belong in the result, how their order is determined, how much data a request can return, and what moving to the next page means when records change.
This article uses a hypothetical assessment-results list to explore those decisions. The Tech Career Assessment portfolio includes assessment workflows, role scoping, and PostgreSQL. That makes the domain relevant, but the schema, query patterns, and performance examples below are illustrative. They do not describe measured production behavior or claim a particular implementation for that project.
Begin with the question the list answers
Consider an administrator who wants the latest completed assessments for a particular program. The screen needs a participant label, completion time, outcome summary, and a link to details. It does not need every answer, every report section, or every associated activity event. That distinction should shape the database projection from the beginning.
Write the access predicate separately from optional filters. The actor's permitted program or organization set is a security boundary. A selected status, date range, or search term narrows the visible result within that boundary. Treating authorization as just another optional filter makes it easier for a later refactor to omit it accidentally.
Next, define the ordering. Latest completed assessments is incomplete when several records share a completion timestamp. Add a unique tie-breaker, such as a stable record identifier, so the order is deterministic. The interface, query, index candidate, and pagination token must agree on the complete ordering tuple.
Finally, set a bounded page size and decide whether users truly need a total count. A precise count of every matching historical record is a separate capability from displaying the next twenty results. Combining them into one unavoidable request can make a fast list depend on work the user did not ask to see.
Inspect plans with representative data
PostgreSQL's EXPLAIN describes the planner's chosen operations and estimates. EXPLAIN ANALYZE actually executes the statement and reports observed execution details, which matters when investigating a query that can mutate data. Estimated costs are planner units rather than elapsed milliseconds. These distinctions are explained in the official Using EXPLAIN documentation.
For the hypothetical list, build a representative dataset with uneven program sizes, repeated timestamps, and a realistic mixture of completed and incomplete records. A uniform development dataset can conceal the exact cases that make a production query expensive. Include one program with a long history and another with very few matching rows.
Record the request shape with the plan. The same SQL text can behave differently for a narrow date range, a broad date range, and a selective program. Without the parameter context, a plan screenshot is difficult to interpret and even harder to compare after a change.
Use the plan to form a hypothesis rather than immediately demanding an index scan. Ask which operation dominates work, where estimates diverge from reality, and whether sorting or repeated lookups expand the cost. Then change one relevant variable and measure again. The desired outcome is a better query for the intended workload, not a visually fashionable plan.
Design indexes around actual access patterns
A candidate index for the example might begin with the mandatory program equality filter and continue with the completion timestamp and stable identifier used for ordering. Whether that is appropriate depends on the actual predicates, data distribution, sort direction, and planner behavior. An index name that sounds correct is not evidence that the query will use it effectively.
PostgreSQL documents that multicolumn B-tree indexes are generally most efficient when conditions constrain leading columns. Later-column conditions can still matter, and the planner has additional strategies, including skip scans in suitable cases. Avoid simplistic rules that every query touching an indexed column must become cheap. Review the multicolumn index documentation for the deployed PostgreSQL version.
Write down the family of requests that each index is intended to support. A program-scoped list sorted by completion time is different from a global staff search sorted by participant name. Trying to cover every possible filter combination with a single wide index can create a complicated design without a clear operational benefit.
Evaluate the write side as well. Assessment completion inserts or updates records, and indexes add maintenance work. The useful comparison includes the list latency improvement, index size, write overhead, and whether the index duplicates an existing access path. Keep only indexes whose purpose can be explained through an important request pattern.
Choose pagination semantics deliberately
PostgreSQL notes that LIMIT and OFFSET require a predictable ordering for consistent subsets, and that skipped OFFSET rows still need to be computed. That makes a deep offset a workload characteristic worth testing rather than assuming every page costs the same. The official LIMIT and OFFSET documentation provides the underlying behavior.
Offset pagination can be a reasonable choice for a small bounded administration list or an interface that requires direct page numbers. The problem is allowing an unbounded history to inherit that choice without testing later pages. Product requirements should determine whether jumping to page two hundred is useful enough to justify its cost.
For a continuously browsed history, consider a cursor based on the complete ordering tuple. In the example, the next page can select records after the last observed completion-time and identifier pair in the chosen ordering. This expresses continuation without requiring the database to discard every earlier page.
Define the token's meaning explicitly. It should belong to the active filters, sort order, and authorized scope. Encoding a token does not authorize access, so the server still applies current permissions. Validate token shape and bounds, and reject a token that cannot represent a legitimate continuation of the requested list.
Explain what happens when records change
Stable ordering solves ties, but it does not automatically create a snapshot across multiple requests. A participant may complete an assessment while an administrator is moving through pages. Another record may be corrected or removed. The product needs a defined expectation for whether those changes can appear during browsing.
For the hypothetical operational list, a live view may be appropriate. New results appear when the administrator refreshes, while a cursor continues from the last boundary already seen. The interface can describe this as browsing recent results without promising an immutable export. Avoid implying stronger consistency than the query actually provides.
A report download has different needs. If the user expects a complete reproducible set, design a separate export or snapshot workflow with an explicit cutoff and consistency strategy. Stretching an interactive list API into an archival reporting system creates hidden requirements around long-running reads, resource limits, and records that change during traversal.
Test updates to sort keys. If completion time can be edited after publication, a record may move across a cursor boundary. Either select an ordering field whose behavior matches the product contract or explain the limitations. A cursor is a continuation rule, not a universal guarantee against movement in mutable data.
Bound related data and serialization work
A database query can be fast while the endpoint is still slow because it fetches and serializes too much related information. For the assessment list, return the summary fields needed to render rows and load detailed answers only when the administrator opens a result. This is also a useful boundary for limiting accidental exposure of private content.
Inspect relation expansion. Twenty results with several collections each can turn a small list into hundreds of objects. If a display field needs an aggregate, decide whether the aggregate is computed in the query, maintained as a projection, or requested separately. Each choice has a freshness and maintenance cost that should be visible.
Be careful with application-side filtering after pagination. Fetching a page and then removing unauthorized or nonmatching rows can produce short pages, incorrect continuation, and confusing counts. Mandatory scope and supported filters should normally shape the database query that defines membership in the page.
Include response size in performance review. A request returning a hundred kilobytes of unused detail can waste browser parsing and rendering time even when the database finishes quickly. Measure the entire path from request receipt to a useful row display, and keep enough instrumentation to identify which stage is responsible for a regression.
Build a compact regression matrix
A meaningful test dataset exercises the decisions the query relies on. Include repeated completion timestamps, the smallest and largest supported page sizes, an empty result, a partially filled final page, and a program with many more records than the others. Check that every returned row remains within the actor's permitted scope.
For cursor pagination, walk the complete unchanged fixture and compare the combined identifiers with the expected ordered set. Assert that none are missing or duplicated. Repeat with a boundary that contains several identical timestamps; this catches implementations that forget the unique tie-breaker when constructing the continuation predicate.
Test malformed and mismatched tokens. A token from one sort order should not quietly produce confusing results under another. A token from a different scope must never bypass authorization. A syntactically valid token referencing a no-longer-visible boundary should follow a documented error or continuation policy rather than leaking hidden record details.
Performance checks should use tolerances appropriate to the environment. A local integration test can verify query count, projection shape, and bounded results without pretending that one laptop predicts production latency. Controlled load tests and production observations supply different evidence. Keep those layers distinct so a passing test does not become an unsupported performance guarantee.
Roll out a query change with evidence
Before changing an established list, record its public behavior. Capture ordering, default filters, null handling, pagination metadata, and empty-state responses. A faster query that changes which records users can discover may be a product regression. Compare the candidate against the existing behavior on representative fixtures before comparing execution time.
If an index is required, plan its rollout using the database's supported operational procedures and the project's migration conventions. Query optimization should not become an excuse to improvise schema changes directly in a production console. The deployment needs an owner, a verification step, and a clear response if the change does not help.
Observe a small set of useful measures after rollout: endpoint latency distribution, database time, rows returned, response size, and failures grouped by request shape. Avoid including personal assessment content or participant search strings in logs. Sanitized categories are usually sufficient to identify which query family needs investigation.
Keep a rollback path for the application query when practical, but remember that compatibility extends to pagination tokens issued by the previous version. If token semantics change, version or invalidate them intentionally and provide a clear restart behavior. A browser holding an old token should not receive an unexplained internal error.
Keep the contract understandable as the screen grows
List screens tend to accumulate requests for another filter, another column, and another sort option. Treat each addition as a change to the access pattern. Ask whether it fits the existing index strategy, whether it changes cursor semantics, and whether the result still has a bounded cost under realistic data volumes.
A useful review artifact is a short table maintained alongside the endpoint: supported filters, mandatory scope, ordering tuple, maximum page size, continuation behavior, and the reason for each important index. This gives future engineers enough context to modify the query without reverse engineering decisions from production incidents.
Resist measuring success only on the first page. The endpoint is reliable when its important request patterns remain explainable and bounded, and when exceptions are visible before users encounter them. An occasional expensive reporting request should be identified as such rather than hidden behind the same contract as an interactive list.
The lasting improvement is a connection between product intent and database work. When the question, ordering, permissions, projection, and continuation rule are explicit, query plans become easier to interpret and indexes become easier to justify. Growth then prompts measured adjustments instead of emergency guesses about why a once-simple page has become slow.
Primary sources
- 1.Using EXPLAIN — PostgreSQL
- 2.Multicolumn Indexes — PostgreSQL
- 3.LIMIT and OFFSET — PostgreSQL
Portfolio evidence
Related writing