- 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. Concept / Definition
- 2. Why It Exists
- 3. Core Concepts
- 4. How It Works Internally — From SELECT to Rows
- 4A. Deep Dive — Pagination Patterns
- 5. APIs / Features / Syntax
- 6. Practical Examples
- 7. Common Problems & Mistakes
- 8. Performance & Production Considerations
- 9. Architecture & Design Considerations
- 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.
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)
| Clause | Purpose |
|---|---|
| SELECT | Columns and expressions to return (projection) |
| FROM / JOIN | Row sources |
| WHERE | Row filter (selection) |
| ORDER BY | Result order |
| DISTINCT | Remove duplicate result rows |
| LIMIT / OFFSET / FETCH | Restrict number of rows |
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
| Guideline | Reason |
|---|---|
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 transferred | e.g. count(*) instead of fetching rows |
| Use expressions carefully in SELECT | Row-level computation is cheap; avoid per-row subqueries on large results |
// 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

| Predicate form | Sargable (index-friendly)? |
|---|---|
col = :v, col IN (…), col BETWEEN a AND b, col > :v | Yes |
col LIKE 'abc%' | Yes (with suitable collation/ops) |
lower(col) = :v | Only with expression index |
col + 1 = :v, date(col) = :d | No — rewrite as range |
col LIKE '%abc' | No (trigram index needed) |
a = :x OR b = :y | Partially (bitmap OR of two indexes) |

Parameters, not concatenation: always bind values (:customerId, ?) — for SQL-injection safety and plan reuse.
3.3 ORDER BY
| Rule | Reason |
|---|---|
| Without ORDER BY, row order is undefined | It can change after VACUUM, parallel plans, upgrades |
| Make ordering deterministic (tie-breaker, e.g. PK) | Stable pagination, reproducible tests |
| Index matching ORDER BY avoids sorting | LIMIT can stop early |
| Specify NULL placement when it matters | NULLS LAST for "due date" lists |
| Collation affects text order | ORDER BY name COLLATE "en-IN-x-icu" for locale-aware sorting |
SELECT ticket_id, due_on, priority
FROM support_ticket
WHERE status = 'OPEN'
ORDER BY due_on ASC NULLS LAST, priority DESC, ticket_id; -- deterministic3.4 DISTINCT

| Form | Meaning |
|---|---|
SELECT DISTINCT a, b | Unique combinations of (a, b) |
SELECT DISTINCT ON (customer_id) … ORDER BY customer_id, placed_at DESC | PostgreSQL: first row per customer (e.g. latest order) |
count(DISTINCT col) | Number of unique values |
-- 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 step | Physical realisation (examples) |
|---|---|
| FROM | Seq Scan, Index Scan, Bitmap Scan per table |
| WHERE | Index Cond (seek) or Filter (per-row check) |
| SELECT expressions | Computed per output row |
| DISTINCT | HashAggregate or Sort + Unique |
| ORDER BY | Sort node, or ordered index scan (no sort) |
| LIMIT | Limit node — stops pulling rows early |
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
| Pattern | SQL | Cost at page N | Stability |
|---|---|---|---|
| OFFSET/LIMIT | ORDER BY placed_at DESC, id DESC OFFSET 4000 LIMIT 20 | Reads and discards 4,000 rows | Rows shift with inserts |
| Keyset (seek) | WHERE (placed_at, id) < (:p, :id) ORDER BY placed_at DESC, id DESC LIMIT 20 | Constant (index seek) | Stable |
| Cursor (server-side) | DECLARE c CURSOR …; FETCH 20 | Holds transaction open | For batch processing only |
// 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
| Feature | Use |
|---|---|
LIMIT n / FETCH FIRST n ROWS ONLY / WITH TIES | Top-N |
IS [NOT] DISTINCT FROM | NULL-safe comparison |
DISTINCT ON (…) | First row per group (PostgreSQL) |
NULLS FIRST/LAST, COLLATE | Ordering control |
ILIKE, SIMILAR TO, regex ~ | Text matching |
Spring JdbcClient → records | Explicit projections |
Spring Data derived queries, @Query, projections | Repository retrieval |
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".
-- 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';| Version | Plan | Time |
|---|---|---|
date(placed_at) = … | Seq Scan, Filter | 2.8 s |
| Range predicate | Index Scan on ix_order_placed_at | 4 ms |
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.
-- 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);| Why | Explanation |
|---|---|
x NOT IN (1, 2, NULL) | = x <> 1 AND x <> 2 AND x <> NULL → UNKNOWN → row excluded |
NOT EXISTS | Tests existence of a matching row; NULLs don't poison it |
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.
// 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) | Loop | Set-based |
|---|---|---|
| Round trips | 1,001 | 1 |
| Elapsed | ~1.8 s | ~25 ms |
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.
-- 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;| units | Returned with LIMIT 3 | With FETCH … WITH TIES |
|---|---|---|
| 120, 95, 90, 90, 70 | 120, 95, 90 (arbitrary one of the 90s) | 120, 95, 90, 90 |
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
| Aspect | Note |
|---|---|
| Columns selected | Fewer columns → less I/O, possible index-only scans |
| Filters | Sargable predicates enable index seeks |
| Sorting | Large sorts spill to disk (work_mem) — prefer index order |
| Row counts | Always bound result sets (LIMIT, pagination) |
| Round trips | One set-based query beats many small ones |
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
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.
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.
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.
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.
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.
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.
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.
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.