ArchitectMind
  1. Home
  2. Tutorials
  3. Book 6 · Databases, APIs & Integration
  4. Volume II
  5. Data Retrieval
Book 6 · Volume II — SQL, Transactions, Concurrency & Database Performance · Topic 1.1

Data Retrieval

SELECT, WHERE, ORDER BY and DISTINCT

11 min readStage 3 · Systems8 interview questions
Chapter
Chapter 1 — SQL Fundamentals
Prerequisites
Volume I (schema, keys, indexes)
You will be able to
Write SELECT queries that return exactly what is needed, understand logical evaluation order, filter correctly with three-valued logic and sargable predicates, sort deterministically and efficiently, use DISTINCT and DISTINCT ON appropriately, and map retrieval queries to efficient Java data access
Covers
SELECT; WHERE; ORDER BY; DISTINCT
On this page
  1. 1. Concept / Definition
  2. 2. Why It Exists
  3. 3. Core Concepts
  4. 4. How It Works Internally — From SELECT to Rows
  5. 4A. Deep Dive — Pagination Patterns
  6. 5. APIs / Features / Syntax
  7. 6. Practical Examples
  8. 7. Common Problems & Mistakes
  9. 8. Performance & Production Considerations
  10. 9. Architecture & Design Considerations
  11. 10. Interview Questions & Answers

1Concept / Definition

Data retrieval in SQL is expressed with the SELECT statement: a declarative description of which rows and columns you want, in which order. The database decides how to fetch them.

SQL
SELECT o.order_id, o.placed_at, o.total  -- projection: which columns
  FROM purchase_order o  -- source
 WHERE o.customer_id = 101  -- selection: which rows
   AND o.status = 'PAID'
 ORDER BY o.placed_at DESC, o.order_id DESC  -- ordering (deterministic)
 FETCH FIRST 20 ROWS ONLY;  -- limit (standard form of LIMIT 20)
Logical order of SELECT evaluation
Figure 1 — Logical order of SELECT evaluation
ClausePurpose
SELECTColumns and expressions to return (projection)
FROM / JOINRow sources
WHERERow filter (selection)
ORDER BYResult order
DISTINCTRemove duplicate result rows
LIMIT / OFFSET / FETCHRestrict number of rows
Table 1 — Clauses

2Why It Exists

SQL's SELECT derives from relational algebra: selection (σ), projection (π) and joins. Because it is declarative, the same query keeps working as data grows and indexes change — the optimizer adapts the execution. This independence is why SQL has outlived every generation of application frameworks built on top of it.

3Core Concepts

3.1 SELECT

GuidelineReason
List columns explicitly (no SELECT * in code)Stable contracts; less I/O; enables index-only scans
Alias computed columns (AS total_incl_tax)Readable result mapping
Compute in SQL when it reduces data transferrede.g. count(*) instead of fetching rows
Use expressions carefully in SELECTRow-level computation is cheap; avoid per-row subqueries on large results
Table 2 — SELECT guidelines
JAVA
// Map exactly the selected columns to a record
public record OrderSummary(long orderId, Instant placedAt, BigDecimal total) {}
 
List<OrderSummary> recent = jdbc.sql("""
        SELECT order_id, placed_at, total
          FROM purchase_order
         WHERE customer_id = :customerId AND status = 'PAID'
         ORDER BY placed_at DESC, order_id DESC
         LIMIT 20""")
    .param("customerId", customerId)
    .query(OrderSummary.class)
    .list();

3.2 WHERE

WHERE filters rows before they are returned
Figure 2 — WHERE filters rows before they are returned
Predicate formSargable (index-friendly)?
col = :v, col IN (…), col BETWEEN a AND b, col > :vYes
col LIKE 'abc%'Yes (with suitable collation/ops)
lower(col) = :vOnly with expression index
col + 1 = :v, date(col) = :dNo — rewrite as range
col LIKE '%abc'No (trigram index needed)
a = :x OR b = :yPartially (bitmap OR of two indexes)
Table 3 — Sargability
Three-valued logic: NULL in WHERE
Figure 3 — Three-valued logic: NULL in WHERE

Parameters, not concatenation: always bind values (:customerId, ?) — for SQL-injection safety and plan reuse.

3.3 ORDER BY

RuleReason
Without ORDER BY, row order is undefinedIt can change after VACUUM, parallel plans, upgrades
Make ordering deterministic (tie-breaker, e.g. PK)Stable pagination, reproducible tests
Index matching ORDER BY avoids sortingLIMIT can stop early
Specify NULL placement when it mattersNULLS LAST for "due date" lists
Collation affects text orderORDER BY name COLLATE "en-IN-x-icu" for locale-aware sorting
Table 4 — ORDER BY rules
SQL
SELECT ticket_id, due_on, priority
  FROM support_ticket
 WHERE status = 'OPEN'
 ORDER BY due_on ASC NULLS LAST, priority DESC, ticket_id;  -- deterministic

3.4 DISTINCT

DISTINCT removes duplicate result rows
Figure 4 — DISTINCT removes duplicate result rows
FormMeaning
SELECT DISTINCT a, bUnique combinations of (a, b)
SELECT DISTINCT ON (customer_id) … ORDER BY customer_id, placed_at DESCPostgreSQL: first row per customer (e.g. latest order)
count(DISTINCT col)Number of unique values
Table 5 — DISTINCT forms
SQL
-- Latest order per customer (PostgreSQL DISTINCT ON)
SELECT DISTINCT ON (customer_id) customer_id, order_id, placed_at, total
  FROM purchase_order
 ORDER BY customer_id, placed_at DESC;

Smell: DISTINCT added to "fix" duplicates caused by a join to a one-to-many table usually hides a wrong query and costs a sort/hash. Fix the join (use EXISTS, aggregate first, or join the right grain).

4How It Works Internally — From SELECT to Rows

Logical stepPhysical realisation (examples)
FROMSeq Scan, Index Scan, Bitmap Scan per table
WHEREIndex Cond (seek) or Filter (per-row check)
SELECT expressionsComputed per output row
DISTINCTHashAggregate or Sort + Unique
ORDER BYSort node, or ordered index scan (no sort)
LIMITLimit node — stops pulling rows early
Table 6 — Logical to physical

Because evaluation is logical, the optimizer may reorder steps as long as results are equivalent — e.g. apply filters inside index scans, or use an index to deliver rows already sorted.

4ADeep Dive — Pagination Patterns

PatternSQLCost at page NStability
OFFSET/LIMITORDER BY placed_at DESC, id DESC OFFSET 4000 LIMIT 20Reads and discards 4,000 rowsRows shift with inserts
Keyset (seek)WHERE (placed_at, id) < (:p, :id) ORDER BY placed_at DESC, id DESC LIMIT 20Constant (index seek)Stable
Cursor (server-side)DECLARE c CURSOR …; FETCH 20Holds transaction openFor batch processing only
Table 7 — Pagination approaches
JAVA
// Spring Data JPA: offset paging (fine for small tables / admin screens)
Page<OrderSummary> page = orders.findByCustomerId(customerId,
        PageRequest.of(pageNo, 20,
                Sort.by(Sort.Order.desc("placedAt"), Sort.Order.desc("id"))));
 
// Keyset scrolling for large feeds
Window<OrderSummary> window = orders.findTop20ByCustomerIdOrderByPlacedAtDescIdDesc(
        customerId, ScrollPosition.keyset());

5APIs / Features / Syntax

FeatureUse
LIMIT n / FETCH FIRST n ROWS ONLY / WITH TIESTop-N
IS [NOT] DISTINCT FROMNULL-safe comparison
DISTINCT ON (…)First row per group (PostgreSQL)
NULLS FIRST/LAST, COLLATEOrdering control
ILIKE, SIMILAR TO, regex ~Text matching
Spring JdbcClient → recordsExplicit projections
Spring Data derived queries, @Query, projectionsRepository retrieval
Table 8 — Retrieval features

6Practical Examples

6.1 SELECT * broke an integration

A partner export used SELECT * and positional mapping; adding a column shifted fields and sent wrong data for a day. Explicit column lists and name-based mapping prevented recurrence.

6.2 Undefined order in tests

A test asserted the first returned row without ORDER BY; it passed for months and failed after a PostgreSQL upgrade enabled a parallel plan. Deterministic ORDER BY fixed the flakiness.

6.3 Worked example — making a filter sargable

Scenario. The daily-orders screen in an e-commerce back office took almost three seconds to load, although purchase_order had an index on placed_at. The developer had written the filter the way the business described it: "orders placed on 25 September".

SQL
-- Slow: function on the column prevents index use (Seq Scan on 30 M rows)
SELECT order_id FROM purchase_order WHERE date(placed_at) = DATE '2026-09-25';
 
-- Fast: half-open range on the raw column (Index Scan)
SELECT order_id FROM purchase_order
 WHERE placed_at >= TIMESTAMPTZ '2026-09-25 00:00+05:30'
   AND placed_at <  TIMESTAMPTZ '2026-09-26 00:00+05:30';
VersionPlanTime
date(placed_at) = …Seq Scan, Filter2.8 s
Range predicateIndex Scan on ix_order_placed_at4 ms
Table 9 — Effect of sargability

The explicit +05:30 offset also makes the "day" boundary unambiguous (IST), avoiding a classic time-zone bug.

What it shows. Wrapping the column in date() forces the database to compute date(placed_at) for every one of the 30 million rows before it can compare, so the index on placed_at cannot be used and the plan is a sequential scan. Rewriting the condition as a half-open range on the raw column, from midnight on the 25th up to but not including midnight on the 26th, lets the planner do an index range scan, cutting the query from 2.8 seconds to 4 milliseconds. The explicit +05:30 offset also makes "the day" unambiguous for a business operating in India, whatever the session time zone.

Takeaway. Keep indexed columns bare on one side of the comparison. If a function is unavoidable, create an expression index on exactly that function, and always confirm with EXPLAIN that the plan uses the index you expect.

6.4 Worked example — the NOT IN / NULL trap

Scenario. Marketing asked for customers who have never placed an order. The obvious NOT IN query returned zero rows in production, although everyone knew thousands of such customers existed. The same query had worked in the test database.

SQL
-- Customers who never placed an order — returns NO rows if any order has customer_id NULL
SELECT c.customer_id FROM customer c
 WHERE c.customer_id NOT IN (SELECT o.customer_id FROM purchase_order o);
 
-- Correct and efficient (anti-join)
SELECT c.customer_id FROM customer c
 WHERE NOT EXISTS (SELECT 1 FROM purchase_order o WHERE o.customer_id = c.customer_id);
WhyExplanation
x NOT IN (1, 2, NULL)= x <> 1 AND x <> 2 AND x <> NULL → UNKNOWN → row excluded
NOT EXISTSTests existence of a matching row; NULLs don't poison it
Table 10 — Three-valued logic in action

What it shows. Production contained a few guest orders with customer_id NULL. NOT IN expands to a chain of inequality tests, and comparing anything with NULL yields UNKNOWN, so for every customer the whole condition becomes UNKNOWN and WHERE discards the row. The test database simply had no NULLs. NOT EXISTS asks a different question, whether a matching row exists, so NULLs in the subquery cannot affect it. Most planners also execute NOT EXISTS as an efficient anti-join.

Takeaway. Use NOT EXISTS for "has no matching rows" queries by default. Treat NOT IN with a subquery as a code-review warning, and make columns NOT NULL wherever the data model allows, because NULLs change the meaning of many SQL constructs.

6.5 Worked example — replacing a Java loop with one query

Scenario. A customer-overview page showed each customer's latest order. With 500 customers on the page it issued 501 queries, one for the list and one per customer, and took over a second even though each individual query was fast.

JAVA
// Before: N+1 — one query per customer to get their latest order
for (Customer c : customers) {
    Order latest = orders.findFirstByCustomerIdOrderByPlacedAtDesc(c.getId());
    …
}
 
// After: one set-based query with DISTINCT ON
record LatestOrder(long customerId, long orderId, Instant placedAt) {}
List<LatestOrder> latest = jdbc.sql("""
        SELECT DISTINCT ON (customer_id) customer_id, order_id, placed_at
          FROM purchase_order
         WHERE customer_id = ANY(:ids)
         ORDER BY customer_id, placed_at DESC""")
    .param("ids", customerIds.toArray(Long[]::new))
    .query(LatestOrder.class).list();
Metric (1,000 customers)LoopSet-based
Round trips1,0011
Elapsed~1.8 s~25 ms
Table 11 — Set-based retrieval

What it shows. The loop is the classic N+1 problem: the cost is dominated by round trips to the database, not by the queries themselves. The replacement sends all customer IDs in one parameter and uses PostgreSQL's DISTINCT ON (customer_id) with ORDER BY customer_id, placed_at DESC, which keeps exactly the newest order per customer. One query and one round trip return all 500 rows, mapped directly into a LatestOrder record. On other databases, a window function such as ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY placed_at DESC) achieves the same result.

Takeaway. When code loops over results and queries inside the loop, think in sets instead. Database round trips are expensive; one well-written set-based query almost always beats many small ones, and statement-count assertions in tests catch regressions.

6.6 Worked example — top-N with ties

Scenario. The home page shows "Top 3 products this month". Two products tied for third place with 90 units each, and depending on the execution plan, sometimes one appeared and sometimes the other, which confused the merchandising team and made caches inconsistent.

SQL
-- Top 3 products by units sold; include ties for 3rd place
SELECT product_id, units
  FROM product_sales_30d
 ORDER BY units DESC
 FETCH FIRST 3 ROWS WITH TIES;
unitsReturned with LIMIT 3With FETCH … WITH TIES
120, 95, 90, 90, 70120, 95, 90 (arbitrary one of the 90s)120, 95, 90, 90
Table 12 — Deterministic top-N

What it shows. LIMIT 3 simply stops after three rows, and when rows tie on the sort key, which one comes third is undefined. FETCH FIRST 3 ROWS WITH TIES, from the SQL standard and supported by PostgreSQL 13 and later, returns every row that ties with the last one, so both products with 90 units appear and the result is deterministic. If the page must show exactly three items, add a tie-breaker to ORDER BY instead, such as product_id, so the choice is at least stable.

Takeaway. Every top-N or paged query needs a deterministic order. Decide explicitly whether ties should all be shown or broken by a secondary key, and make that business decision visible in the SQL.

7Common Problems & Mistakes

  • `SELECT ** in application code
  • Functions on indexed columns in WHERE.
  • NOT IN with nullable subquery columns.
  • Missing or non-deterministic ORDER BY.
  • DISTINCT masking bad joins.
  • OFFSET pagination deep into large result sets.
  • String-concatenated SQL (injection, no plan reuse).

8Performance & Production Considerations

AspectNote
Columns selectedFewer columns → less I/O, possible index-only scans
FiltersSargable predicates enable index seeks
SortingLarge sorts spill to disk (work_mem) — prefer index order
Row countsAlways bound result sets (LIMIT, pagination)
Round tripsOne set-based query beats many small ones
Table 13 — Performance

9Architecture & Design Considerations

  • Define read models/projections per use case rather than loading full entities.
  • Require deterministic ordering and bounded results for every API list.
  • Standardise keyset pagination for large or infinite lists.
  • Review SQL in code reviews with plans for critical queries.

10Interview Questions & Answers

Q1Basic

What is the logical order of evaluation of a SELECT statement?

SQL is written in one order but evaluated logically in another. The database first builds the row source from FROM and any JOINs, filters rows with WHERE, groups them with GROUP BY, filters groups with HAVING, computes the SELECT list including expressions and window functions, removes duplicates with DISTINCT, sorts with ORDER BY, and finally applies LIMIT or OFFSET. This explains several rules: a column alias defined in SELECT cannot be used in WHERE, because WHERE runs earlier; aggregate conditions belong in HAVING, not WHERE; and ORDER BY can use aliases because it runs after SELECT. The optimiser may reorder physically, but results must match this logical order.

Q2Basic

Why avoid SELECT * in application code?

SELECT * fetches every column, including large text or JSON columns the code never uses, which wastes I/O, network bandwidth and memory. It makes code fragile: adding, removing or reordering columns can silently change what the application receives, or break mapping code that relies on positions. It also prevents index-only scans, where the database answers a query entirely from an index without touching the table, because the index rarely contains every column. Listing the needed columns, or using projections in JPA, documents what the code depends on. SELECT * remains fine for ad hoc queries in a console.

Q3Intermediate

Why does WHERE city <> 'Pune' not return rows where city is NULL?

SQL uses three-valued logic: a comparison can be TRUE, FALSE or UNKNOWN. When city is NULL, meaning unknown, the expression city <> 'Pune' cannot be decided, so it evaluates to UNKNOWN rather than TRUE. WHERE keeps only rows for which the condition is TRUE, so rows with NULL city are silently excluded. To include them, write WHERE city <> 'Pune' OR city IS NULL, or use the standard IS DISTINCT FROM operator, which treats NULL as a comparable value. The same logic causes surprises with NOT IN and with aggregates, so NOT NULL constraints are worth adding wherever possible.

Q4Intermediate

What is a sargable predicate?

A sargable predicate, from "search argument able", is a condition the database can answer with an index seek or range scan. Typically the indexed column appears bare on one side of a comparison with a constant or parameter, such as placed_at >= :from or customer_id = :id. Wrapping the column in a function or expression, such as date(placed_at) = :day, lower(email) = :email or amount * 1.18 > 1000, makes the predicate non-sargable, so the database must evaluate every row. Fix it by rewriting the condition, for example as a range, or by creating an expression index that matches the function exactly.

Q5Senior Engineer

Why must pagination have a deterministic ORDER BY?

Offset pagination fetches page N by sorting all rows and skipping the earlier ones. If the sort key is not unique, rows with the same value have no defined order, and the database may return them in a different order on each query. A row can then appear on two pages or never appear at all, especially when data changes between requests. Adding a unique column, usually the primary key, as the final sort key, such as ORDER BY created_at DESC, id DESC, makes the order fully deterministic. The same deterministic order is required for keyset pagination and stable exports.

Q6Senior Engineer

When is DISTINCT a code smell?

DISTINCT is often added to make duplicate rows disappear without understanding why they appeared. The usual cause is a join to a one-to-many table, such as joining customers to orders to find customers with recent orders: each customer appears once per order, and DISTINCT hides that. The query still produces and sorts all the duplicates, which is slow, and the logic is unclear. EXISTS expresses the real question, "customers that have at least one matching order", directly and lets the planner use a semi-join. DISTINCT is appropriate when you genuinely need unique values, such as a list of cities.

Q7Technical Architect

Why prefer keyset pagination for large lists?

Offset pagination gets slower as users go deeper: to return page 1,000, the database must read and discard all the rows before it. Results also shift when rows are inserted or deleted between requests, so users see duplicates or miss rows. Keyset pagination remembers the sort key of the last row shown and asks for rows after it, such as WHERE (created_at, id) < (:lastCreated, :lastId) ORDER BY created_at DESC, id DESC LIMIT 50. With a matching index, every page costs the same, however deep, and concurrent inserts do not shift pages. The trade-off is no random jumping to page N, which large lists rarely need.

Q8Solution Architect

How do you standardise data retrieval across services?

Agree on a small set of rules and support them with shared tooling. Each use case queries a projection with only the columns it needs, never whole entities for list screens. Every list endpoint has a maximum page size and a deterministic ORDER BY, and large lists use keyset pagination. All queries are parameterised, never built by string concatenation. Critical queries have their execution plans reviewed and are covered by tests that assert statement counts. Provide these as shared libraries, code-review checklists and API guidelines, and monitor slow queries in production, so teams follow the rules by default rather than rediscovering them.

Newsletter

Get new tutorials every week

New ArchitectMind tutorials and interview questions, once a week. No spam, unsubscribe anytime.

Almost done! Check your inbox and click the confirmation link to complete your subscription.
Keep going in Book 6

Databases, APIs & Integration

Volume I · Topic 1.1

Why Databases Exist

Database Fundamentals & Relational Data Modeling

Volume I · Topic 1.2

Database Management Systems

Database Fundamentals & Relational Data Modeling

Volume I · Topic 1.3

Database Architecture

Database Fundamentals & Relational Data Modeling

Volume II · Topic 1.2

Data Modification

SQL, Transactions, Concurrency & Database Performance

Volume II · Topic 1.3

Aggregation

SQL, Transactions, Concurrency & Database Performance

Volume III · Topic 1.1

JDBC Architecture

Java Persistence & Data Access Architecture

Volume III · Topic 1.2

PreparedStatement

Java Persistence & Data Access Architecture

Volume III · Topic 1.3

JDBC Transactions

Java Persistence & Data Access Architecture

Volume IV · Topic 1.1

Why NoSQL Exists

NoSQL, Caching & Polyglot Persistence

Volume IV · Topic 1.2

Categories

NoSQL, Caching & Polyglot Persistence

Volume IV · Topic 1.3

Trade-offs

NoSQL, Caching & Polyglot Persistence

Volume V · Topic 1.1

HTTP Architecture

API Design, HTTP & REST

Volume V · Topic 1.2

Methods

API Design, HTTP & REST

Volume V · Topic 1.3

Status Codes

API Design, HTTP & REST

Volume VI · Topic 1.1

Gateway Responsibilities

API Management, GraphQL & Distributed API Design

Volume VI · Topic 1.2

Gateway Patterns

API Management, GraphQL & Distributed API Design

Volume VI · Topic 1.3

Gateway Risks

API Management, GraphQL & Distributed API Design

Volume VII · Topic 1.1

Why Systems Integrate

Enterprise Integration & Messaging

Volume VII · Topic 1.2

Integration Styles

Enterprise Integration & Messaging

Volume VII · Topic 1.3

Point-to-Point Integration

Enterprise Integration & Messaging

Volume VIII · Topic 1.1

Integration Boundaries

Integration Architecture & Data Consistency

Volume VIII · Topic 1.2

Synchronous vs Asynchronous

Integration Architecture & Data Consistency

Volume VIII · Topic 1.3

Coupling

Integration Architecture & Data Consistency