SQL · free · no signup

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

Basics SQL · 15 min · 15 XP

SQL 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.
The mistake that costs people the interview: Testing for missing values with = NULL. Nothing equals NULL, not even NULL. Use IS NULL / IS NOT NULL.

Practice challenge

Count StudentsBasics
Task

Count total number of students.

Expected output
count 150
Show a hint
  1. Use COUNT(*) to count rows
  2. COUNT counts all records
  3. Returns a single number

Open this exercise in the app → · Auto-graded coding rounds

Check yourself

1. How do you test for a missing value?

  1. = NULL
  2. == NULL
  3. IS NULL
  4. NULL()
Show answer

C. IS NULL

2. Which sorts highest salary first?

  1. ORDER BY salary
  2. ORDER BY salary DESC
  3. SORT salary DESC
  4. ORDER salary DOWN
Show answer

B. ORDER BY salary DESC

3. Which quotes are correct for text in standard SQL?

  1. 'Finance'
  2. "Finance"
  3. `Finance`
  4. (Finance)
Show answer

A. 'Finance'

Back to the syllabus ↑

GROUP BY and aggregation

Working level SQL · 18 min · 25 XP

Aggregation 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.
The mistake that costs people the interview: Putting an aggregate in WHERE (WHERE COUNT(*) > 5). It fails, because WHERE runs before grouping β€” that condition belongs in HAVING.

Practice challenge

Aliasing ColumnsWorking level
Task

Use AS to create column aliases.

Expected output
student_name | student_marks Raj | 92
Show a hint
  1. column_name AS alias
  2. AS is optional: name alias works too
  3. Useful for aggregate functions

Open this exercise in the app → · Auto-graded coding rounds

Check yourself

1. Which filters groups after aggregation?

  1. WHERE
  2. HAVING
  3. FILTER
  4. GROUP FILTER
Show answer

B. HAVING

2. How do COUNT(*) and COUNT(col) differ?

  1. They are identical
  2. COUNT(col) ignores NULLs
  3. COUNT(*) is faster only
  4. COUNT(col) counts distinct
Show answer

B. COUNT(col) ignores NULLs

3. A non-aggregated SELECT column must also appear where?

  1. ORDER BY
  2. GROUP BY
  3. HAVING
  4. WHERE
Show answer

B. GROUP BY

Back to the syllabus ↑

JOINs across tables

Advanced SQL · 20 min · 30 XP

Real 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.
The mistake that costs people the interview: Filtering the right table in WHERE after a LEFT JOIN. WHERE o.status = 'paid' quietly converts it back into an INNER JOIN β€” put that condition in the ON clause instead.

Practice challenge

CASE WHEN ConditionalAdvanced
Task

Use CASE WHEN for conditional logic.

Expected output
name | marks | status Raj | 92 | Pass Amir | 35 | Fail
Show a hint
  1. CASE WHEN condition THEN result
  2. Multiple WHEN clauses allowed
  3. ELSE for default case

Open this exercise in the app → · Auto-graded coding rounds

Check yourself

1. Which keeps customers who have no orders?

  1. INNER JOIN
  2. LEFT JOIN
  3. CROSS JOIN
  4. JOIN
Show answer

B. LEFT JOIN

2. What does COALESCE(SUM(total), 0) do?

  1. Rounds the total
  2. Replaces NULL with 0
  3. Counts rows
  4. Sorts results
Show answer

B. Replaces NULL with 0

3. Where should a right-table filter go in a LEFT JOIN?

  1. WHERE
  2. The ON clause
  3. HAVING
  4. ORDER BY
Show answer

B. The ON clause

Back to the syllabus ↑

Subqueries and CTEs

Working level SQL · 18 min · 25 XP

A 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.
The mistake that costs people the interview: Using a subquery that returns several rows with = instead of IN. The database raises an error rather than guessing which row you meant.

Practice challenge

Average Marks by ClassWorking level
Task

Find average marks grouped by class.

Expected output
class | avg_marks 10 | 78.5 9 | 76.2 8 | 75.8
Show a hint
  1. Use AVG() for average
  2. GROUP BY class
  3. ORDER BY for sorting results

Open this exercise in the app → · Auto-graded coding rounds

Check yourself

1. What keyword starts a CTE?

  1. WITH
  2. USING
  3. DECLARE
  4. LET
Show answer

A. WITH

2. A subquery returning many rows should be compared with…

  1. =
  2. IN
  3. LIKE
  4. IS
Show answer

B. IN

3. Why prefer a CTE over deep nesting?

  1. It is always faster
  2. It is far more readable
  3. It uses less storage
  4. It avoids indexes
Show answer

B. It is far more readable

Back to the syllabus ↑

Window functions

Advanced SQL · 22 min · 35 XP

A 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.
The mistake that costs people the interview: Trying to filter on a window function in WHERE. It is computed after WHERE runs β€” wrap the query in a CTE and filter outside it.

Practice challenge

Common Table Expression (CTE)Advanced
Task

Use WITH clause for CTE.

Expected output
name | marks Raj | 92 Priya | 88
Show a hint
  1. WITH creates temporary named result
  2. CTE scoped to single query
  3. Can have multiple CTEs separated by comma

Open this exercise in the app → · Auto-graded coding rounds

Check yourself

1. What does a window function keep that GROUP BY loses?

  1. Every individual row
  2. Indexes
  3. Column names
  4. Nothing
Show answer

A. Every individual row

2. Which never produces a tie?

  1. RANK
  2. DENSE_RANK
  3. ROW_NUMBER
  4. NTILE
Show answer

C. ROW_NUMBER

3. Where can you filter on a window function's result?

  1. WHERE
  2. In an outer query or CTE
  3. HAVING
  4. ON
Show answer

B. In an outer query or CTE

Back to the syllabus ↑

Changing data safely

Working level SQL · 15 min · 20 XP

INSERT 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.
The mistake that costs people the interview: Running UPDATE without WHERE. It updates every row in the table, and without a transaction there is no undo.

Practice challenge

BETWEEN OperatorWorking level
Task

Use BETWEEN for range filtering.

Expected output
name | marks Akshay | 85 Amir | 82 Zara | 78
Show a hint
  1. BETWEEN value1 AND value2
  2. BETWEEN is inclusive (includes endpoints)
  3. Equivalent to >= AND <=

Open this exercise in the app → · Auto-graded coding rounds

Check yourself

1. What does UPDATE without WHERE affect?

  1. Nothing
  2. One row
  3. Every row in the table
  4. It errors
Show answer

C. Every row in the table

2. What should you run before an UPDATE?

  1. The same filter as a SELECT
  2. A DELETE
  3. TRUNCATE
  4. Nothing
Show answer

A. The same filter as a SELECT

3. What is a soft delete?

  1. DELETE with a WHERE
  2. Marking a row deleted instead of removing it
  3. Dropping the table
  4. A backup
Show answer

B. Marking a row deleted instead of removing it

Back to the syllabus ↑

Dates, time series and cohorts

Advanced SQL · 16 min · 20 XP

Most 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.
The mistake that costs people the interview: Filtering with WHERE applied_on BETWEEN '2026-01-01' AND '2026-01-31' and losing everything that happened on the 31st after midnight, because the timestamp is compared against 00:00:00.

Practice challenge

Complex JOIN with AggregationAdvanced
Task

Combine JOINs, GROUP BY, and subquery.

Expected output
name | marks | avg_marks Raj | 92 | 78.5
Show a hint
  1. Use CTE to precompute class averages
  2. JOIN to attach averages
  3. Find max average then filter students

Open this exercise in the app → · Auto-graded coding rounds

Check yourself

1. Why prefer >= start AND < next_start over BETWEEN for timestamps?

  1. It is faster
  2. BETWEEN excludes both ends
  3. BETWEEN includes the end date at midnight only, losing that day's later rows
  4. There is no difference
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?

  1. Missing rows
  2. Divide by zero
  3. Duplicate months
  4. Timezone drift
Show answer

B. Divide by zero

3. Why truncate to month rather than compare raw timestamps?

  1. It is faster
  2. It groups every row in the month together correctly
  3. It avoids NULLs
  4. It sorts alphabetically
Show answer

B. It groups every row in the month together correctly

Back to the syllabus ↑

Why queries are slow

Job-ready SQL · 16 min · 25 XP

At 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.
The mistake that costs people the interview: Adding an index for every column that appears in a WHERE clause. Each index slows every write and consumes space; index what is actually queried often, then measure with EXPLAIN rather than guessing.

Practice challenge

EXISTS SubqueryAdvanced
Task

Use EXISTS to check subquery results.

Expected output
class 8 9
Show a hint
  1. EXISTS returns true if any rows
  2. NOT EXISTS for negation
  3. More efficient than IN for large datasets

Open this exercise in the app → · Auto-graded coding rounds

Check yourself

1. Why is WHERE DATE(applied_on) = '2026-08-01' slow?

  1. DATE() is an expensive function
  2. The function on the column prevents index use
  3. Strings compare slowly
  4. It returns more rows
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?

  1. (source)
  2. (source, applied_on)
  3. (applied_on, source)
  4. two separate single-column indexes
Show answer

C. (applied_on, source)

3. What is the cost of adding an index for every WHERE column?

  1. Nothing, indexes are free
  2. Slower writes and more storage
  3. Queries return wrong rows
  4. The table locks permanently
Show answer

B. Slower writes and more storage

Back to the syllabus ↑

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

Keep reading

The STAR method, properly: how to build answers that hold up
A working guide to STAR interview answers: how to weight each part, how to build five stories that cover most…
Returning to work after a career break: rebuilding confidence and explaining the gap
How to present a career break on your CV, close the confidence gap, and answer interview questions about time…
Free AI interview coach
Free AI interview coach: voice mock interviews that talk back, role-specific questions, coding practice and…
Interview countdown, prediction & mock practice
Free interview prep: a live countdown to your interview date, then the 15 most common questions as flip-cards…