Learn sql, with practice after every lesson
8 lessons, about 140 minutes of reading, and 24 multiple-choice questions. Each lesson names the mistake that most often costs people the interview, because that is where the hours actually go. Part of the free coding course.
SELECT, WHERE and ORDER BY
BasicsSQL asks a database a question. Nearly every analyst and many non-technical office roles now expect it, and it is the fastest technical skill to become genuinely employable in β weeks rather than months. It is also the one most commonly tested live in an interview, because a screen share and a sample table tell an interviewer more in ten minutes than an hour of discussion.
Every query answers three things: which columns you want (SELECT), from where (FROM), and which rows qualify (WHERE). Everything else is refinement on top of that. Once that shape is fixed in your head, most queries you meet are readable even when they are long, because they are the same three questions with more conditions attached.
The trap that catches everyone once is NULL. It does not mean zero and it does not mean empty β it means unknown, and nothing equals unknown, not even another unknown. So WHERE region = NULL returns no rows, silently, rather than the missing ones you were looking for. IS NULL and IS NOT NULL are the only tests that work, and the day that clicks is the day your row counts start matching reality.
Syntax
SELECT name, department, salary
FROM employees
WHERE department = 'Finance'
AND salary > 50000
ORDER BY salary DESC
LIMIT 10;
Key points
- String comparison uses single quotes: 'Finance', not "Finance".
- ORDER BY ... DESC sorts high to low; the default is ascending.
- LIMIT caps the rows returned β essential when exploring a large table.
Practice challenge
Count total number of students.
count 150
Show a hint
- Use COUNT(*) to count rows
- COUNT counts all records
- Returns a single number
Check yourself
1. How do you test for a missing value?
Show answer
C. IS NULL
2. Which sorts highest salary first?
Show answer
B. ORDER BY salary DESC
3. Which quotes are correct for text in standard SQL?
Show answer
A. 'Finance'
GROUP BY and aggregation
Working levelAggregation answers questions about groups rather than rows: revenue per region, headcount per department, average order value per month. This is where SQL stops being a lookup tool and starts being analysis, and it is the point at which most people stop copying queries and begin writing their own.
The rule that trips everyone is the difference between WHERE and HAVING. WHERE filters rows before grouping; HAVING filters the groups afterwards. Interviewers ask this constantly because it separates people who understand the execution order from people who memorised syntax β and because putting an aggregate in WHERE is such a natural mistake that everyone makes it once.
The other thing worth internalising early is that COUNT(*) and COUNT(column) are different questions. COUNT(*) counts rows; COUNT(column) skips NULLs. On a table where half the emails are missing, those two numbers differ by half, and a report built on the wrong one is wrong in a way that looks entirely plausible until somebody checks.
Syntax
SELECT department,
COUNT(*) AS headcount,
AVG(salary) AS avg_salary
FROM employees
WHERE active = 1 -- filters rows first
GROUP BY department
HAVING COUNT(*) > 5 -- filters groups after
ORDER BY avg_salary DESC;
Key points
- Every non-aggregated column in SELECT must appear in GROUP BY.
- COUNT(*) counts rows; COUNT(column) skips NULLs β they give different answers.
- Alias with AS so the output is readable by whoever receives it.
Practice challenge
Use AS to create column aliases.
student_name | student_marks Raj | 92
Show a hint
- column_name AS alias
- AS is optional: name alias works too
- Useful for aggregate functions
Check yourself
1. Which filters groups after aggregation?
Show answer
B. HAVING
2. How do COUNT(*) and COUNT(col) differ?
Show answer
B. COUNT(col) ignores NULLs
3. A non-aggregated SELECT column must also appear where?
Show answer
B. GROUP BY
JOINs across tables
AdvancedReal databases split data across tables to avoid repetition β customers in one, orders in another. A JOIN reassembles them, and it is the single most-tested SQL topic in analyst interviews, because it is where correctness and confidence most often diverge.
An INNER JOIN keeps only rows that match on both sides. A LEFT JOIN keeps every row from the left table, filling gaps with NULL. Choosing the wrong one silently drops data, which is why a query can look right and quietly under-report: the customers who never ordered simply vanish from the result, and nothing in the output says they were there.
The subtlety that catches experienced people is filtering the right table in WHERE after a LEFT JOIN. WHERE o.status = 'paid' discards the rows where o.status is NULL β that is, exactly the customers with no orders β converting your LEFT JOIN back into an INNER JOIN without any error. If you meant to keep them, the condition belongs in the ON clause instead.
Syntax
SELECT c.name,
COUNT(o.id) AS orders,
COALESCE(SUM(o.total), 0) AS spend
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.name
ORDER BY spend DESC;
Key points
- LEFT JOIN keeps customers with zero orders; INNER JOIN would hide them entirely.
- COALESCE(x, 0) replaces NULL with a usable number after a LEFT JOIN.
- Alias tables (customers c) so the ON condition stays readable.
Practice challenge
Use CASE WHEN for conditional logic.
name | marks | status Raj | 92 | Pass Amir | 35 | Fail
Show a hint
- CASE WHEN condition THEN result
- Multiple WHEN clauses allowed
- ELSE for default case
Check yourself
1. Which keeps customers who have no orders?
Show answer
B. LEFT JOIN
2. What does COALESCE(SUM(total), 0) do?
Show answer
B. Replaces NULL with 0
3. Where should a right-table filter go in a LEFT JOIN?
Show answer
B. The ON clause
Subqueries and CTEs
Working levelA subquery is a query inside a query β useful when the filter you need is itself the result of a calculation, such as everyone earning above the company average. You cannot put an aggregate directly in WHERE, so the calculation has to happen somewhere else first, and a subquery is that somewhere.
A CTE (WITH ... AS) does the same job but names the intermediate step. Once a query grows past a few lines, CTEs make it readable, and interviewers notice when a candidate reaches for them: a query built from three named steps can be explained out loud, and a triple-nested subquery usually cannot.
The practical difference is debugging. A CTE can be run on its own to check what it returns before you join it to anything, so when a number is wrong you can find which step produced it in about a minute. Deeply nested subqueries have to be unpicked from the inside out, which is why long queries written that way tend to be rewritten rather than fixed.
Syntax
-- Subquery: above-average earners
SELECT name, salary
FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);
-- Same idea, readable as a CTE
WITH dept_avg AS (
SELECT department, AVG(salary) AS avg_sal
FROM employees
GROUP BY department
)
SELECT e.name, e.salary, d.avg_sal
FROM employees e
JOIN dept_avg d ON d.department = e.department
WHERE e.salary > d.avg_sal;
Key points
- A scalar subquery returns one value and can sit anywhere a value is allowed.
- A CTE is defined once with WITH and can then be joined like a normal table.
- CTEs can be chained, each building on the last β far clearer than deep nesting.
Practice challenge
Find average marks grouped by class.
class | avg_marks 10 | 78.5 9 | 76.2 8 | 75.8
Show a hint
- Use AVG() for average
- GROUP BY class
- ORDER BY for sorting results
Check yourself
1. What keyword starts a CTE?
Show answer
A. WITH
2. A subquery returning many rows should be compared withβ¦
Show answer
B. IN
3. Why prefer a CTE over deep nesting?
Show answer
B. It is far more readable
Window functions
AdvancedA window function calculates across a set of rows while still returning every row. GROUP BY collapses ten rows into one; a window function keeps all ten and adds the aggregate alongside. That single difference is what lets you show each employee's salary next to their department average on the same line.
This is the clearest dividing line between basic and strong SQL in an interview. Ranking within a category, running totals and period-over-period comparisons all need it, and they are extremely common analyst tasks β the request 'show me each region's top three products' has no clean answer without one.
PARTITION BY splits the data into groups and ORDER BY sets the order inside each one. The three ranking functions differ in how they handle ties: RANK leaves gaps after a tie, DENSE_RANK does not, and ROW_NUMBER never ties at all. Picking the wrong one produces a top-three that silently contains four rows, or drops a legitimate tie.
Syntax
SELECT
name,
department,
salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank,
AVG(salary) OVER (PARTITION BY department) AS dept_avg,
salary - LAG(salary) OVER (ORDER BY hire_date) AS vs_previous
FROM employees;
Key points
- PARTITION BY splits into groups; ORDER BY sets the order inside each one.
- RANK leaves gaps after ties; DENSE_RANK does not; ROW_NUMBER never ties.
- LAG and LEAD reach to the previous or next row β the basis of period-over-period analysis.
Practice challenge
Use WITH clause for CTE.
name | marks Raj | 92 Priya | 88
Show a hint
- WITH creates temporary named result
- CTE scoped to single query
- Can have multiple CTEs separated by comma
Check yourself
1. What does a window function keep that GROUP BY loses?
Show answer
A. Every individual row
2. Which never produces a tie?
Show answer
C. ROW_NUMBER
3. Where can you filter on a window function's result?
Show answer
B. In an outer query or CTE
Changing data safely
Working levelINSERT adds rows, UPDATE changes them, DELETE removes them. Unlike a SELECT, these change the database β and an UPDATE without a WHERE clause changes every single row in the table, instantly, with no confirmation and no undo unless somebody wrapped it in a transaction.
The professional habit is to write the statement as a SELECT first, confirm it returns exactly the rows you intend, and only then convert it to an UPDATE or DELETE. It takes ten extra seconds and it is the difference between a routine change and an incident report. Write the WHERE clause before you write the SET clause β habit beats memory at six in the evening.
Most mature teams also avoid hard deletes entirely, setting a deleted_at timestamp instead so the row survives and history stays intact. If you are asked in an interview how you would remove a customer's records, the answer that shows experience mentions soft deletes, transactions and whether anything downstream depends on those rows.
Syntax
-- 1. Check what you are about to touch
SELECT * FROM employees WHERE id = 42;
-- 2. Only then change it
UPDATE employees
SET salary = 62000, updated_at = NOW()
WHERE id = 42;
INSERT INTO employees (name, department, salary)
VALUES ('Amit', 'Finance', 48000);
DELETE FROM employees WHERE id = 42;
Key points
- Always write the WHERE clause before you write SET β habit beats memory at 6pm.
- Wrap multi-statement changes in a transaction so they succeed or fail together.
- Many teams never hard-delete; they set a deleted_at timestamp so history survives.
Practice challenge
Use BETWEEN for range filtering.
name | marks Akshay | 85 Amir | 82 Zara | 78
Show a hint
- BETWEEN value1 AND value2
- BETWEEN is inclusive (includes endpoints)
- Equivalent to >= AND <=
Check yourself
1. What does UPDATE without WHERE affect?
Show answer
C. Every row in the table
2. What should you run before an UPDATE?
Show answer
A. The same filter as a SELECT
3. What is a soft delete?
Show answer
B. Marking a row deleted instead of removing it
Dates, time series and cohorts
AdvancedMost analyst questions are really date questions: how many this month, is it growing, do users who joined in January behave like those who joined in June. Getting comfortable with dates is what separates someone who can answer follow-up questions from someone who can only run the query they were given.
Two habits matter. Truncate to the grain you are reporting on rather than comparing raw timestamps, because a timestamp carries a time component that will not match anything you compare it to directly. And always be explicit about the boundary β 'on or after the first, before the first of next month' avoids the off-by-one-day error that quietly misstates every monthly number.
That boundary problem is worth spelling out because it is so common. BETWEEN '2026-01-01' AND '2026-01-31' looks like it covers January, but a timestamp of 31 January at 09:15 is greater than 31 January at 00:00 and is therefore excluded. You lose most of the last day, every month, and the totals stay plausible enough that nobody notices.
Syntax
-- applications per month, with month-over-month change
WITH monthly AS (
SELECT
DATE_TRUNC('month', applied_on) AS month,
COUNT(*) AS applications
FROM applications
WHERE applied_on >= '2026-01-01'
AND applied_on < '2027-01-01'
GROUP BY 1
)
SELECT
month,
applications,
applications - LAG(applications) OVER (ORDER BY month) AS change,
ROUND(
100.0 * (applications - LAG(applications) OVER (ORDER BY month))
/ NULLIF(LAG(applications) OVER (ORDER BY month), 0), 1
) AS pct_change
FROM monthly
ORDER BY month;
Key points
- DATE_TRUNC('month', ts) groups by month correctly; strftime or substring on a timestamp usually breaks on timezones.
- Use >= start AND < next_start for ranges. BETWEEN includes both ends and silently double-counts the boundary day.
- NULLIF(x, 0) is the standard guard against divide-by-zero in a percentage change.
Practice challenge
Combine JOINs, GROUP BY, and subquery.
name | marks | avg_marks Raj | 92 | 78.5
Show a hint
- Use CTE to precompute class averages
- JOIN to attach averages
- Find max average then filter students
Check yourself
1. Why prefer >= start AND < next_start over BETWEEN for timestamps?
Show answer
C. BETWEEN includes the end date at midnight only, losing that day's later rows
2. What does NULLIF(previous, 0) protect against?
Show answer
B. Divide by zero
3. Why truncate to month rather than compare raw timestamps?
Show answer
B. It groups every row in the month together correctly
Why queries are slow
Job-readyAt some point a query that worked on ten thousand rows takes four minutes on ten million. Almost always the cause is the same: the database is reading every row because it has no usable index for what you asked, or because the way you wrote the condition made an existing index unusable.
You do not need to memorise query plans to be useful here. Knowing that an index is a sorted lookup, that a function wrapped around a column defeats it, and that EXPLAIN will tell you which is happening puts you ahead of most people who write SQL daily. WHERE DATE(created_at) = '2026-08-01' cannot use an index on created_at; rewriting it as a range on the bare column can.
The other half is knowing when not to add one. Every index slows down writes and takes storage, so indexing every column mentioned in a WHERE clause makes inserts slower and buys little. Index what is queried often, in the order it is filtered, and measure with EXPLAIN rather than guessing β a composite index on (date, region) helps a date filter, while (region, date) does not.
Syntax
-- Slow: the function on the column defeats any index on applied_on
SELECT * FROM applications
WHERE DATE(applied_on) = '2026-08-01';
-- Fast: the range leaves the column bare, so the index is usable
SELECT * FROM applications
WHERE applied_on >= '2026-08-01'
AND applied_on < '2026-08-02';
-- Ask the database what it is actually doing
EXPLAIN ANALYZE
SELECT source, COUNT(*)
FROM applications
WHERE applied_on >= '2026-01-01'
GROUP BY source;
-- An index that serves the filter AND the grouping
CREATE INDEX idx_apps_date_source
ON applications (applied_on, source);
Key points
- Wrapping a column in a function β DATE(col), UPPER(col), col::text β usually forces a full scan. Rewrite the condition to leave the column bare.
- A composite index is ordered. (applied_on, source) helps a filter on applied_on; (source, applied_on) does not help a date-only filter.
- SELECT * pulls every column across the network. Naming the three you need is often the single cheapest speedup available.
Practice challenge
Use EXISTS to check subquery results.
class 8 9
Show a hint
- EXISTS returns true if any rows
- NOT EXISTS for negation
- More efficient than IN for large datasets
Check yourself
1. Why is WHERE DATE(applied_on) = '2026-08-01' slow?
Show answer
B. The function on the column prevents index use
2. Which index best serves a filter on applied_on then a group by source?
Show answer
C. (applied_on, source)
3. What is the cost of adding an index for every WHERE column?
Show answer
B. Slower writes and more storage
Common questions
Do I need any background to start SQL?
No. This track begins at its own beginning and assumes nothing, and the first lesson explains what the thing is before showing you any syntax.
How long does the SQL track take?
About 140 minutes of reading across 8 lessons, plus the practice challenges and 24 multiple-choice questions, which is where the time actually goes.
Is it free?
Yes, and there is no account. Everything runs in your browser.
More: all 15 tracks · what employers actually ask for · the full syllabus