Every query has the same skeleton
SELECT full_name, class_level
FROM students
WHERE class_level = 9
ORDER BY joined_on DESC
LIMIT 20;FROM names where the rows come from. WHERE decides which rows survive. SELECT decides which columns of the survivors you see. ORDER BY sorts them. LIMIT cuts the list short.
Here is the thing that clears up half of all beginner confusion: you write SELECT first, but the database runs FROM first, then WHERE, then SELECT. The rows are chosen before the columns are picked. That is why this works perfectly well:
SELECT full_name FROM students WHERE phone IS NOT NULL;You filtered on a column you never asked to see. Nothing strange happened. WHERE was looking at whole rows; SELECT trimmed them afterwards.
WHERE tests one row at a time
This is the mental model to hold. The database walks the table, takes one row, evaluates your condition against the values *in that row only*, and keeps it if the answer is true. It cannot see the previous row. It cannot see the other rows belonging to the same customer. One row, in isolation, every time.
So given a payments table where Ade has two rows — 3,000 naira in cash and 8,000 naira by transfer:
SELECT student_id FROM payments
WHERE method = 'cash' AND amount > 5000;Ade does not appear. No single row of his is both cash and over 5,000. If you wanted "students who have paid cash *and* who have some payment over 5,000", that is a different question and needs the grouping you will meet in a later lesson.
Writing conditions
Text goes in single quotes. Double quotes mean something else entirely (a column name), and using them for text is one of the first errors you will hit.
WHERE method = 'upi' -- exact match, case sensitive
WHERE method IN ('upi', 'card', 'cash') -- any of these
WHERE amount >= 5000 AND amount < 20000 -- a range
WHERE full_name LIKE 'A%' -- starts with A
WHERE full_name ILIKE '%kumar%' -- contains, case-insensitive (Postgres)For an apostrophe inside text, double it: WHERE city = 'Coeur d''Alene'.
The AND/OR trap. AND binds tighter than OR, exactly like multiplication binds tighter than addition. This:
WHERE country = 'NG' OR country = 'KE' AND amount > 5000means country = 'NG' or (country = 'KE' and amount > 5000). Every Nigerian payment comes back, whatever the amount. The database is not wrong; you did not say what you meant. Put brackets in whenever you mix them, even when you are sure:
WHERE (country = 'NG' OR country = 'KE') AND amount > 5000Dates. Prefer half-open ranges over BETWEEN when the column stores a time as well as a date:
WHERE paid_on >= '2026-03-01' AND paid_on < '2026-04-01'BETWEEN '2026-03-01' AND '2026-03-31' looks equivalent and quietly drops everything that happened on 31 March after midnight, because 2026-03-31 14:02 is greater than 2026-03-31 00:00. That bug has cost more than one company a month-end report.
Look before you count
The habit that will save you the most time is boring. Before you aggregate anything, run the query with LIMIT 20 and read the rows with your eyes. Do the amounts look like amounts. Do the dates land in the range you expected. Is that column full of the string 'N/A'.
A count is a single number and a single number cannot look wrong. Twenty rows can look wrong immediately.
Before you move on