SQL Basics: SELECT, WHERE and ORDER BY

SQL (Structured Query Language) is the standard language for querying relational databases. A SELECT statement reads rows without changing anything: it is the read in CRUD. FROM names the table to read, and three clauses do most of the work: SELECT names the columns, WHERE keeps the rows that match a condition and ORDER BY sorts the result.

For example, SELECT order_id, status FROM orders WHERE customer_id = 207 ORDER BY created_at DESC; lists the orders of customer 207, newest first. Testers use queries like this to see what the application really stored and to work out expected results.

Why it matters

The screen and the API show data after the application has filtered and formatted it; the database shows what was stored. When a test fails, or passes for the wrong reason, a short query shows whether the defect is in the data or in the code that displays it, and gives the developer evidence to rerun.

SELECT is also where beginners make silent mistakes: a condition written as = NULL, missing parentheses or a list read without ORDER BY raises no error. It returns the wrong rows, and a tester who trusts them passes a broken feature or reports a bug that does not exist.

How a SELECT statement is built

SQL is declarative: you describe the result and the database decides how to produce it. The standard ISO/IEC 9075 defines the language; each engine, such as PostgreSQL, MySQL, SQLite or SQL Server, implements a dialect of it, and the basics here work in all of them unless a note says otherwise.

A statement is made of clauses that start with keywords. Keywords ignore case, but capitals keep queries readable. Text goes in single quotes ('PAID'); in standard SQL and PostgreSQL, double quotes mean a column or table name. -- starts a comment and ; ends the statement. The result is itself a table, and zero rows is a valid answer, not an error.

The clauses are evaluated, logically, in a different order from the one they are written in: FROM, WHERE, the SELECT list, ORDER BY, then LIMIT. So an alias created with AS in the SELECT list works in ORDER BY but not in WHERE, which runs before the alias exists. PostgreSQL and MySQL reject it there; SQLite accepts it, so a query that works in SQLite practice can fail on the project's engine.

A SELECT is written SELECT, FROM, WHERE, ORDER BY, LIMIT but evaluated FROM, WHERE, SELECT, ORDER BY, LIMIT; the pins number the clauses of this query in that logical order.

Paid orders of customer 207, newest first

-- Paid orders of customer 207, newest first
Part 3:SELECT order_id, total_amount AS total, created_at
Part 1:FROM orders
Part 2:WHERE customer_id = 207
  AND status = 'PAID'
Part 4:ORDER BY created_at DESC, order_id DESC
Part 5:LIMIT 5;
  • 1–2Which rows
  • 3Which columns
  • 4–5In what order, and how many
  1. 1FROM: the tableEvaluated first: names the table whose rows every later clause works on.written 2nd
  2. 2WHERE: the rowsKeeps only rows for which the whole condition is true; rows where it is false or unknown because of a NULL are dropped.written 3rd
  3. 3SELECT list: the columnsChooses the columns and their names. The alias total exists from here on, so ORDER BY may use it; standard SQL does not allow it in WHERE.written 1st
  4. 4ORDER BY: the orderNewest first; order_id DESC breaks ties between orders created at the same moment. Without this clause the order is not defined.written 4th
  5. 5LIMIT: how manyKeeps the first 5 rows of the sorted result. SQL Server writes TOP (5); the standard form is FETCH FIRST 5 ROWS ONLY.written 5th

The numbers show the logical order that defines the result, not the order in which the engine physically reads the data.

Text version
-- Paid orders of customer 207, newest first
SELECT order_id, total_amount AS total, created_at
FROM orders
WHERE customer_id = 207
  AND status = 'PAID'
ORDER BY created_at DESC, order_id DESC
LIMIT 5;

Try it: SQL Formatter Try it: SQL Query Validator

Choosing columns with SELECT and rows with WHERE

Name the columns you check, as in SELECT order_id, status, and keep SELECT * for a quick look, so that saved checks read well in a bug report. AS renames an output column, and SELECT DISTINCT status FROM orders lists each value once: a quick way to spot values nobody expected, such as 'CANCELED' beside 'CANCELLED'.

WHERE is evaluated for every row, and only the rows for which it is true are returned. It is built from these parts:

  • Comparisons: =, <> (most engines also accept !=), <, <=, >, >=.
  • Logic: AND is evaluated before OR, so status = 'PAID' OR status = 'SHIPPED' AND total_amount > 500 returns all paid orders plus the shipped ones over 500. Use parentheses to say what you mean.
  • Lists and ranges: status IN ('PAID', 'SHIPPED'); BETWEEN 100 AND 300 includes both ends. For timestamps use a half-open range, which includes its start and excludes its end: created_at >= '2026-09-01' AND created_at < '2026-10-01'.
  • Patterns: LIKE '%@example.com', where % matches any sequence of characters, including none, and _ exactly one. Case sensitivity depends on the engine and the column's collation, its rules for comparing and sorting text: PostgreSQL compares text case-sensitively, MySQL's default collations do not, and SQLite's LIKE ignores the case of ASCII letters while its = does not.

Try it: SQL IN Clause Generator

Sorting the result with ORDER BY

Without ORDER BY the order of rows is not defined: it can change when the data grows, an index (a structure the database uses to find rows faster) is added or the engine is upgraded. Any check that reads “the first row” or compares a list with the screen must sort explicitly.

ORDER BY created_at DESC, order_id DESC uses the second column only when the first is equal; ASC is the default. End with a unique column such as the primary key: without that tie-breaker, equal rows may swap between runs, and with paging a row can appear on two pages or on none.

  • LIMIT 10 keeps the first ten rows after sorting. SQL Server writes TOP (10); the standard form, FETCH FIRST 10 ROWS ONLY, works in PostgreSQL but not in MySQL or SQLite.
  • Text sorts by the column's collation, and numbers stored as text sort as text: '10' comes before '9'. A price list sorted like that on screen is a reason to check the column's data type.

NULL: the value that is not there

NULL marks a missing or unknown value; it is not zero, not an empty string '' and not the text 'NULL'. A comparison with NULL is neither true nor false but unknown, and WHERE keeps only true rows, so rows where the compared column is NULL vanish from results without any warning.

Unknown also flows through AND and OR. An AND is true only when both sides are true, so true AND unknown is unknown and the row is dropped; an OR needs one true side, so unknown OR true is true and the row is kept.

  • WHERE email = NULL never returns a row. Use IS NULL and IS NOT NULL.
  • WHERE city <> 'Riyadh' also drops the rows whose city is NULL, and so does NOT (city = 'Riyadh'). If those rows belong in the result, add OR city IS NULL.
  • Arithmetic with NULL gives NULL; COALESCE(delivery_fee, 0) returns its first argument that is not NULL.
  • An empty string is not NULL in PostgreSQL, MySQL and SQLite (Oracle Database stores '' as NULL), so a check for a required field looks for both.
  • In ascending order PostgreSQL sorts NULL last, MySQL and SQLite first; in descending order each does the reverse. PostgreSQL and SQLite accept NULLS FIRST or NULLS LAST to make the position explicit.
-- Customers without a usable email address
SELECT customer_id, full_name
FROM customers
WHERE email IS NULL OR email = '';

-- Show a default where the value is missing
SELECT order_id, COALESCE(coupon_code, 'none') AS coupon
FROM orders
WHERE customer_id = 207;

Worked example: checking a “My orders” page

Requirement: “My orders lists the customer's orders except cancelled ones, newest first; orders placed at the same time appear by order number, highest first; a page shows at most 10 orders.” The tester first writes the query that produces the expected list from the test data, so the test oracle, the source of the expected result, comes from the rule, not from the screen.

Customer 207 has five orders; order 1003 belongs to another customer. Each line of the query checks one part of the rule:

  • customer_id = 207: if order 1003 appears, one customer sees another customer's data, a privacy defect.
  • status <> 'CANCELLED': order 1002 is not expected. status is NOT NULL here; if it allowed NULL, this condition would also hide orders without a status.
  • ORDER BY created_at DESC, order_id DESC: 1004 and 1005 have the same created_at, so the tie-breaker puts 1005 first. A page that shows 1004 first, or swaps them on reload, breaks the rule.
  • LIMIT 10: the page size is a boundary. Test with exactly 10 and 11 non-cancelled orders, as in boundary value analysis: the 11th must appear on page two, and no order twice. LIMIT 10 OFFSET 10 skips the first ten rows and gives the expected page two.
-- Test data: orders
-- order_id  customer_id  status     total_amount  created_at
-- 1001      207          PAID       120.00        2026-09-03 10:15
-- 1002      207          CANCELLED   80.00        2026-09-05 18:40
-- 1003      311          PAID       455.50        2026-09-06 09:05
-- 1004      207          SHIPPED    455.50        2026-09-12 21:30
-- 1005      207          PAID        60.00        2026-09-12 21:30
-- 1006      207          PAID        35.25        2026-09-20 08:00

SELECT order_id, status, total_amount, created_at
FROM orders
WHERE customer_id = 207
  AND status <> 'CANCELLED'
ORDER BY created_at DESC, order_id DESC
LIMIT 10;

-- Expected, top to bottom: 1006, 1005, 1004, 1001

Try it: SQL Dataset Generator

Common mistakes

  • Writing = NULL or <> NULL: the query returns zero rows, so a check that expects zero rows passes for the wrong reason.
  • Forgetting that <> and NOT also drop the rows where the column is NULL.
  • Comparing a result with the screen without ORDER BY or a tie-breaker, then reporting a sorting bug that is really an undefined order.
  • Mixing AND and OR without parentheses, so the filter returns rows the requirement excludes.
  • Using BETWEEN '2026-09-01' AND '2026-09-30' on a timestamp column: it misses every row after 00:00 on 30 September.
  • Trusting an empty result without a control: a misspelt value such as 'CANCELED' for 'CANCELLED' also returns zero rows. First query a row you know exists.

The QA perspective

Testers use SELECT to prepare tests (choosing data and computing expected results), to confirm what an action stored, as in the lesson on CRUD operations, and to separate data problems from display problems when investigating a failure. All of it is reading: use a read-only account in a test environment, and never browse production personal data for convenience.

Treat a query as test evidence: put the statement, the environment and the rows it returned in the test case or bug report, so a developer can rerun it. Ask the developers which columns may be NULL, and ask the product owner what users should see when a value is missing; that answer is the expected result of your NULL cases.

Practice exercise

Review a query for an email campaign

Your task

Marketing wants to email every customer outside Riyadh who has an email address, highest loyalty points first. Using the six sample rows, predict what the developer's query below returns, find its defects and write a corrected query with its result.

Some defects are really questions about the requirement: write them down for the product owner.

Material
-- Test data: customers
-- customer_id  full_name     city    email               loyalty_points
-- 1            Layla Hassan  Riyadh  layla@example.com   120
-- 2            Omar Saleh    Jeddah  NULL                300
-- 3            Priya Nair    Dammam  ''                  NULL
-- 4            Khalid Ali    NULL    khalid@example.com  450
-- 5            Sara Ahmed    Jeddah  sara@example.com    75
-- 6            Rahul Mehta   Dammam  rahul@example.com   NULL

-- The developer's query
SELECT customer_id, full_name, email
FROM customers
WHERE city <> 'Riyadh' AND email <> NULL
ORDER BY loyalty_points;
Steps
  1. Evaluate each part of the WHERE condition for each row: true, false or unknown.
  2. Write down the rows the query returns.
  3. List the defects and the requirement questions they raise.
  4. Write a corrected query and its rows, in order.
Show the model answer
Model answer

city <> 'Riyadh' is false for row 1, true for rows 2, 3, 5 and 6, and unknown for row 4; email <> NULL is unknown for every row.

An AND is true only when both sides are true, so no row qualifies: the query returns no rows, and nothing warns you.

Defects: email <> NULL must be email IS NOT NULL; customer 3 has an empty string, which is not NULL, so also exclude email = ''; the sort must be DESC; customer 6's NULL points sort first or last depending on the engine; and there is no tie-breaker for equal points.

Questions for the product owner: does a customer with no city (customer 4) count as outside Riyadh? Do missing loyalty points count as 0?

If both answers are yes, SELECT customer_id, full_name, email FROM customers WHERE (city <> 'Riyadh' OR city IS NULL) AND email IS NOT NULL AND email <> '' ORDER BY COALESCE(loyalty_points, 0) DESC, customer_id; returns customers 4 (450 points), 5 (75) and 6 (none), in that order. If customers without a city are excluded, it returns 5, then 6.

Frequently asked questions

Do I need to learn the SQL of one particular database?

Start with the engine your project uses. SELECT, WHERE, ORDER BY and IS NULL are written the same way in PostgreSQL, MySQL, SQLite, SQL Server and Oracle Database; the first differences you will meet are row limits, date functions, case sensitivity in text comparison and where NULLs sort. Check those in your engine's documentation when a query moves between projects.

Is it safe to run SELECT on a shared database?

SELECT does not change data, but it is not free: a query over a large table without a selective WHERE can slow the database for everyone, and the rows may hold personal data. Use a read-only account, filter on the primary key or a date range, add LIMIT while exploring, and follow your team's rules for production access.

How can I practise without a database server?

SQLite keeps a whole database in one file and needs no server, only its command-line shell sqlite3, which makes it a common choice for practice. Generate a table and sample rows as a SQL script, load them, and rerun this lesson's queries, predicting each result before you run it. Remember where SQLite is more lenient than most engines, such as accepting an alias in WHERE.

References

Practise with Mutqan tools

Check SQL statements for syntax problems, unbalanced parentheses, unterminated strings and risky patterns.

Database

Format SQL queries with consistent keyword casing, indentation and line breaks (MySQL, PostgreSQL, SQL Server, Oracle, SQLite).

Database

Generate SQL seed scripts (CREATE TABLE + INSERTs) with realistic test rows for any dialect.

Test Data

Turn a list of values (one per line or comma separated) into a quoted SQL IN (...) clause.

Database

Go deeper

Your progress on this topic

Part of the QA Roadmap