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.
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
FROM: the tableEvaluated first: names the table whose rows every later clause works on.written 2nd - 2
WHERE: the rowsKeeps only rows for which the whole condition is true; rows where it is false or unknown because of aNULLare dropped.written 3rd - 3
SELECTlist: the columnsChooses the columns and their names. The aliastotalexists from here on, soORDER BYmay use it; standard SQL does not allow it inWHERE.written 1st - 4
ORDER BY: the orderNewest first;order_id DESCbreaks ties between orders created at the same moment. Without this clause the order is not defined.written 4th - 5
LIMIT: how manyKeeps the first 5 rows of the sorted result. SQL Server writesTOP (5); the standard form isFETCH 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;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:
ANDis evaluated beforeOR, sostatus = 'PAID' OR status = 'SHIPPED' AND total_amount > 500returns 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 300includes 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'sLIKEignores the case of ASCII letters while its=does not.
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 10keeps the first ten rows after sorting. SQL Server writesTOP (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 = NULLnever returns a row. UseIS NULLandIS NOT NULL.WHERE city <> 'Riyadh'also drops the rows whose city isNULL, and so doesNOT (city = 'Riyadh'). If those rows belong in the result, addOR city IS NULL.- Arithmetic with
NULLgivesNULL;COALESCE(delivery_fee, 0)returns its first argument that is notNULL. - An empty string is not
NULLin PostgreSQL, MySQL and SQLite (Oracle Database stores''asNULL), so a check for a required field looks for both. - In ascending order PostgreSQL sorts
NULLlast, MySQL and SQLite first; in descending order each does the reverse. PostgreSQL and SQLite acceptNULLS FIRSTorNULLS LASTto 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.statusisNOT NULLhere; if it allowedNULL, this condition would also hide orders without a status.ORDER BY created_at DESC, order_id DESC: 1004 and 1005 have the samecreated_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 10skips 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, 1001Common mistakes
- Writing
= NULLor<> NULL: the query returns zero rows, so a check that expects zero rows passes for the wrong reason. - Forgetting that
<>andNOTalso drop the rows where the column isNULL. - Comparing a result with the screen without
ORDER BYor a tie-breaker, then reporting a sorting bug that is really an undefined order. - Mixing
ANDandORwithout 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
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.
-- 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;- Evaluate each part of the WHERE condition for each row: true, false or unknown.
- Write down the rows the query returns.
- List the defects and the requirement questions they raise.
- Write a corrected query and its rows, in order.
Show the 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
- ISO/IEC 9075-1:2023 — Information technology — Database languages SQL — Part 1: Framework (SQL/Framework)The framework part of the international SQL standard, which the dialects of PostgreSQL, MySQL, SQLite and others implement to different degrees.
- PostgreSQL Documentation: SQL SyntaxLexical structure of SQL in PostgreSQL: identifiers, quoting, string constants and escaping.
- SQL As Understood By SQLiteThe SQL grammar accepted by SQLite, statement by statement, with railroad diagrams.
Practise with Mutqan tools
Check SQL statements for syntax problems, unbalanced parentheses, unterminated strings and risky patterns.
Format SQL queries with consistent keyword casing, indentation and line breaks (MySQL, PostgreSQL, SQL Server, Oracle, SQLite).
Generate SQL seed scripts (CREATE TABLE + INSERTs) with realistic test rows for any dialect.
Turn a list of values (one per line or comma separated) into a quoted SQL IN (...) clause.
Go deeper
Your progress on this topic
Not started
Mark the topic complete when you have studied it and done the exercise.
Sign in to track your progress
Mark topics complete, continue where you left off and unlock specialisation exams.