What SQL is, and what most tutorials skip
A relational database keeps data in tables. Each table is a grid: rows are the things (one customer, one order), columns are their facts (name, city, price). Tables point at each other through keys: an order row carries the id of exactly one customer row. SQL (Structured Query Language) is how you ask the database for rows, and how you add, change and remove them.
The one idea that makes SQL click is this: you describe the result you want, and the database works out how to get it. You never write a loop. "All paid orders, newest first, top ten" is one statement, and the database decides which index to use and in what order to read the rows. People who come from Python or JavaScript keep trying to loop; people who have never coded often find SQL easier for exactly that reason.
| Dialect | Where you meet it | For this tutorial |
|---|---|---|
| SQLite | Phones, desktop apps, Python's standard library, small projects | Easiest to start: one file, no server, free tools such as DB Browser for SQLite. |
| PostgreSQL | Most new web backends and data teams | The most useful one to grow into. Free. Everything here runs on it. |
| MySQL / MariaDB | Older web apps, WordPress, many hosting plans | Everything here runs on it, with small differences in functions. |
| SQL Server, Oracle | Large companies, finance, government | Same core language; LIMIT is written TOP or FETCH FIRST. |
The core of SQL is a standard shared by all of them. Learn the core first on whichever is easiest to install; switching dialects later takes an afternoon, not a course.
Set up the practice database in ten minutes
Install SQLite (or open any SQL playground), create a new database, and run the statements below. They make three tables and fill them with a small bookshop: six customers in six countries, six books, and a handful of orders. Keep this database; the rest of the tutorial asks it questions.
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
country TEXT NOT NULL,
joined DATE NOT NULL
);
CREATE TABLE books (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
author TEXT,
price NUMERIC NOT NULL
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
book_id INTEGER NOT NULL REFERENCES books(id),
quantity INTEGER NOT NULL,
status TEXT,
placed_at DATE NOT NULL
);
INSERT INTO customers VALUES
(1, 'Amina', 'Kenya', '2026-01-05'),
(2, 'Rafael', 'Brazil', '2026-01-20'),
(3, 'Priya', 'India', '2026-02-02'),
(4, 'Tomas', 'Poland', '2026-02-14'),
(5, 'Hana', 'Japan', '2026-03-01'),
(6, 'Grace', 'Australia', '2026-03-09');
INSERT INTO books VALUES
(1, 'Learning SQL', 'Alan Beaulieu', 30.00),
(2, 'Python Crash Course', 'Eric Matthes', 35.00),
(3, 'Clean Code', 'Robert C. Martin', 40.00),
(4, 'The Pragmatic Programmer', NULL, 45.00),
(5, 'Think Stats', 'Allen Downey', 25.00),
(6, 'Eloquent JavaScript', 'Marijn Haverbeke', 20.00);
INSERT INTO orders VALUES
(1, 1, 1, 1, 'paid', '2026-03-01'),
(2, 2, 2, 2, 'paid', '2026-03-02'),
(3, 1, 3, 1, 'paid', '2026-03-05'),
(4, 3, 1, 1, 'pending', '2026-03-06'),
(5, 4, 5, 3, 'paid', '2026-03-08'),
(6, 2, 6, 1, 'cancelled', '2026-03-09'),
(7, 5, 2, 1, 'paid', '2026-03-10'),
(8, 1, 5, 2, NULL, '2026-03-12');Three things to notice before you query it. PRIMARY KEY means every row has a unique id. REFERENCES means an order must point at a customer and a book that exist. And two cells are NULL: one book has no author recorded and one order has no status. NULL means "unknown", and it will catch you in lesson 2.
Lesson 1: SELECT, FROM, WHERE, ORDER BY, LIMIT
A query names the columns you want, the table they come from, and the condition the rows must meet. Read it in the order the database runs it: FROM (which table), WHERE (which rows), SELECT (which columns), then ORDER BY and LIMIT.
SELECT id, quantity, placed_at
FROM orders
WHERE status = 'paid'
ORDER BY placed_at DESC
LIMIT 3;Before you run it, say how many rows you expect. There are five paid orders; the three newest are orders 7, 5 and 3. Run it. If you got something else, you have found your first bug, and finding it on eight rows is the whole point of a small database.
SELECT *returns every column. Fine while exploring; name the columns in anything you keep.WHEREtakes=,<>(not equal),<,>,BETWEEN,IN ('paid', 'pending'), andLIKE 'A%'for text patterns. Combine withAND,ORand brackets; mixedAND/ORwithout brackets is the first classic mistake.ORDER BYsorts; addDESCfor newest or largest first. Without it, row order is an accident the database may change tomorrow.LIMITcaps the rows. WithORDER BYit answers every "top ten" question.DISTINCTremoves duplicate rows from the result:SELECT DISTINCT country FROM customers;
Try: every customer who joined after 1 February 2026, alphabetically. Every order of two or more copies. The cheapest book. Predict the rows, then run.
Lesson 2: NULL, expressions and CASE
NULL is not zero and not an empty string. It means the fact is unknown, and anything compared with unknown is unknown. So WHERE status = NULL returns nothing, silently, not even an error. The test is IS NULL.
SELECT id FROM orders WHERE status = NULL; -- returns nothing, always
SELECT id FROM orders WHERE status IS NULL; -- order 8
SELECT id FROM orders WHERE status <> 'paid'; -- orders 4 and 6 only: the NULL row is excluded tooWhen rows go missing from a report, the cause is a NULL about eight times in ten. COALESCE(status, 'unknown') replaces a NULL with a value you choose, which is how reports show "unknown" instead of a blank.
Columns can be combined into expressions, and CASE is SQL's if-then-else, inside the SELECT:
SELECT title,
price,
price * 0.9 AS sale_price,
CASE WHEN price >= 40 THEN 'premium'
WHEN price >= 30 THEN 'standard'
ELSE 'budget' END AS tier
FROM books
ORDER BY price DESC;AS gives a column a name for this query. Common functions you will use every week: LOWER, UPPER, LENGTH and TRIM for text; ROUND for numbers; and your dialect's date functions (strftime in SQLite, date_trunc in PostgreSQL) for grouping by month.
Lesson 3: GROUP BY, aggregates and HAVING
Aggregates collapse many rows into one answer: COUNT, SUM, AVG, MIN, MAX. On their own they summarise the whole table. With GROUP BY they summarise each group: per customer, per country, per month.
SELECT customer_id,
COUNT(*) AS orders,
SUM(quantity) AS books_bought
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
ORDER BY books_bought DESC;The rule that explains almost every GROUP BY error: every column in the SELECT is either in the GROUP BY or inside an aggregate. If you ask for placed_at while grouping by customer, there are several dates per customer and no single one to print, so the database refuses (or, in older MySQL, guesses, which is worse).
COUNT(*)counts rows.COUNT(status)counts rows where status is notNULL. On our orders table they give 8 and 7. Two questions, one verb.- Aggregates ignore
NULL, soAVGover a sparse column averages only the known values. Decide whether that is what you meant. WHEREfilters rows before grouping.HAVINGfilters groups after:HAVING SUM(quantity) >= 3keeps only customers who bought three or more books.WHEREcannot see the sum, because it runs first.
Try: how many customers per country. The average book price. Countries with more than one customer (none yet; add a customer and watch it appear).
Lesson 4: JOIN, the lesson that makes SQL useful
Real questions cross tables. "Which customers bought which books" needs orders, customers and books together, and a join does that by matching keys: orders.customer_id to customers.id.
SELECT c.name, c.country, b.title, o.quantity, o.placed_at
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
JOIN books AS b ON b.id = o.book_id
WHERE o.status = 'paid'
ORDER BY o.placed_at;The ON clause says how rows pair up. It is almost always a key equality, and getting it wrong does not produce an error: it produces too many rows, each one looking plausible. This is why you predict the row count first. Five paid orders in, five rows out. If you see twenty-five, a join has fanned out.
| Join | Keeps | Example question |
|---|---|---|
JOIN (inner) | Only rows that match on both sides | Customers who have placed an order |
LEFT JOIN | Every left row; unmatched right columns become NULL | Every customer, including those who never ordered |
RIGHT JOIN | Every right row (rarely needed; swap the tables and use LEFT) | |
| Self join | A table joined to itself through two aliases | Employees and their managers |
The LEFT JOIN version of "customers and their order counts" is the one most beginners get wrong, so here it is in full. Grace, who has never ordered, must appear with zero, and that needs COUNT(o.id), not COUNT(*), because the unmatched rows carry a NULL order id:
SELECT c.name, COUNT(o.id) AS orders
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY orders DESC, c.name;Try: total money taken per book (price times quantity, paid orders only). Which books have never been ordered (a LEFT JOIN where the order side IS NULL).
Lesson 5: subqueries, a question inside a question
Sometimes the answer to one question is the input to another. "Books that cost more than the average" needs the average first. A subquery in brackets returns a value or a list that the outer query uses.
-- books above the average price
SELECT title, price
FROM books
WHERE price > (SELECT AVG(price) FROM books);
-- customers who have at least one paid order
SELECT name
FROM customers
WHERE id IN (SELECT customer_id FROM orders WHERE status = 'paid');
-- the same, the way experienced people write it
SELECT name
FROM customers AS c
WHERE EXISTS (SELECT 1 FROM orders AS o
WHERE o.customer_id = c.id AND o.status = 'paid');IN compares against the subquery's list. EXISTS asks only "is there at least one row", which is usually what you mean and usually faster. Neither multiplies rows, which makes them the safe way to filter one table by another when a join would fan out.
When a query gets long, name the pieces with a WITH clause (a common table expression, or CTE). It reads top to bottom like a short program:
WITH paid AS (
SELECT customer_id, SUM(quantity) AS books_bought
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
)
SELECT c.name, c.country, paid.books_bought
FROM paid
JOIN customers AS c ON c.id = paid.customer_id
WHERE paid.books_bought >= 2;Lesson 6: INSERT, UPDATE, DELETE and designing a table
Reading is most of SQL, but you also add, change and remove rows. The two rules: always write the WHERE before the rest of an UPDATE or DELETE, and run it as a SELECT first to see exactly which rows it will touch.
INSERT INTO orders (id, customer_id, book_id, quantity, status, placed_at)
VALUES (9, 6, 4, 1, 'paid', '2026-03-15');
SELECT * FROM orders WHERE id = 8; -- look first
UPDATE orders SET status = 'paid' WHERE id = 8; -- then change exactly that row
DELETE FROM orders WHERE status = 'cancelled'; -- without WHERE, this empties the tableDesigning a table is deciding what the database will refuse. Every NOT NULL, PRIMARY KEY, REFERENCES and UNIQUE is a promise about every row, and the database keeps it for you forever, which is far cheaper than cleaning bad data later.
- One fact per column, one thing per row. A column holding "red, blue, green" is three facts; make a second table.
- Every table has a primary key. Usually an integer id the database generates.
- Point at other tables with their id, never by copying their name. If the customer renames, one row changes.
- Store money as an exact decimal type (
NUMERIC), never floating point; and store dates as dates, not text, so they sort and subtract correctly. - Use a transaction for changes that belong together:
BEGIN;the statements, thenCOMMIT;(orROLLBACK;if something fails). Moving stock from one shelf to another is one change, not two.
A worked example: ten questions for the bookshop
Here are the questions a bookshop owner would actually ask, in rising difficulty, each with the query. Cover the right-hand column, write your own, then compare. Different wording that returns the same rows is a pass.
| Question | Query |
|---|---|
| Which books cost under 30? | SELECT title FROM books WHERE price < 30; |
| Who joined in February 2026? | SELECT name FROM customers WHERE joined BETWEEN '2026-02-01' AND '2026-02-28'; |
| How many orders have no status? | SELECT COUNT(*) FROM orders WHERE status IS NULL; |
| How many books of each status? | SELECT COALESCE(status, 'unknown') AS status, SUM(quantity) FROM orders GROUP BY status; |
| Which customer bought the most books? | SELECT customer_id, SUM(quantity) AS n FROM orders WHERE status = 'paid' GROUP BY customer_id ORDER BY n DESC LIMIT 1; |
| Name and country of every paying customer | SELECT DISTINCT c.name, c.country FROM customers c JOIN orders o ON o.customer_id = c.id WHERE o.status = 'paid'; |
| Money taken per book title | SELECT b.title, SUM(b.price * o.quantity) AS taken FROM books b JOIN orders o ON o.book_id = b.id WHERE o.status = 'paid' GROUP BY b.id, b.title; |
| Books never ordered | SELECT b.title FROM books b LEFT JOIN orders o ON o.book_id = b.id WHERE o.id IS NULL; |
| Customers who ordered more than once | SELECT customer_id FROM orders GROUP BY customer_id HAVING COUNT(*) > 1; |
| Customers whose every order is paid | SELECT name FROM customers c WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id) AND NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id AND (o.status <> 'paid' OR o.status IS NULL)); |
The last one is the kind of question that separates knowing the keywords from knowing SQL: it needs EXISTS, NOT EXISTS and the NULL rule together. If you can write it without looking, you have finished the beginner stage.
A two-week plan, thirty minutes a day
| Days | Do | You can now |
|---|---|---|
| 1 | Install SQLite or open a playground; create the practice database; run five SELECTs | Get rows out of a table |
| 2 to 3 | Lesson 1: filtering, sorting, limits, DISTINCT; ten questions of your own | Answer any one-table question |
| 4 | Lesson 2: NULL, COALESCE, expressions, CASE | Explain why rows went missing |
| 5 to 6 | Lesson 3: aggregates, GROUP BY, HAVING | Produce a report per group |
| 7 | Rest from SQL; one query from memory | Keep what you have |
| 8 to 10 | Lesson 4: joins, including LEFT JOIN with counts; predict row counts every time | Cross tables without fan-out |
| 11 | Lesson 5: subqueries, EXISTS, a WITH clause | Build a question from two questions |
| 12 | Lesson 6: insert, update, delete, create a table with constraints | Change data safely |
| 13 to 14 | Design your own three-table database about something you know and write the ten questions for it | Use SQL for real |
After the two weeks, keep one query a day from memory for another two weeks (the spaced reviews in How to memorize faster apply to code exactly as to facts). Then learn the parts this tutorial leaves out, in this order: indexes and reading a query plan, window functions (ROW_NUMBER, running totals), and the date functions of your dialect.
The mistakes that keep beginners stuck
- Watching tutorials instead of typing. SQL looks obvious on a screen and falls apart at a blank prompt. Type every query, including the ones you are sure of.
- Not predicting the row count. The prediction is the test. A join that returns 25 rows instead of 5 is only a bug if you knew to expect 5.
- Treating
NULLas a value.= NULLnever matches.<>excludes theNULLrows.COUNT(column)skips them. Learn the three rules once. - Selecting a column you did not group by. Either group by it or wrap it in an aggregate.
- Mixing
ANDandORwithout brackets.ANDbinds tighter; write the brackets even when you do not need them. UPDATEorDELETEwithoutWHERE. Run theSELECTfirst, every time, however small the table.- Learning a dialect's extras before the core. Window functions and JSON columns are useful later; joins and
GROUP BYare what every job needs first. - Starting on a huge sample database. Eight rows you can count by hand teach joins faster than a million rows you cannot check.
Your SQL learning plan
- Install SQLite or open a free SQL playground. Typing beats watching.
- Create the bookshop database: customers, books, orders. Keep it.
- Lesson 1: SELECT, FROM, WHERE, ORDER BY, LIMIT, DISTINCT.
- Lesson 2: NULL (IS NULL, COALESCE), expressions, CASE.
- Lesson 3: COUNT, SUM, AVG, MIN, MAX; GROUP BY; HAVING.
- Lesson 4: JOIN and LEFT JOIN; predict the row count before every run.
- Lesson 5: subqueries, IN, EXISTS, WITH. Lesson 6: INSERT, UPDATE, DELETE with WHERE first; CREATE TABLE with constraints.
- Build your own three-table database and write ten questions for it. One query a day from memory for two weeks.
Save this plan: print the page or save it as a PDF from your browser's menu, or keep the plain-text version.
Frequently asked questions
Can I learn SQL in a week?
You can learn the core of SQL (filtering, sorting, grouping and joins) in about two weeks at thirty minutes a day, if you type every query into a real database and predict the rows before you run them. One week is enough for single-table queries and grouping. Being comfortable with joins and subqueries takes a few more days of practice on your own database.
Which SQL should a beginner learn first: MySQL or PostgreSQL?
Either; the core language is the same and everything in this tutorial runs on both. SQLite is the easiest to install for practice, and PostgreSQL is the most useful to grow into for web and data work. Learn the core first on whichever you can open today, then switch dialects in an afternoon when a job or project needs one.
Do I need to know programming before learning SQL?
No. SQL is declarative: you describe the rows you want and the database finds them, with no loops or variables. Many people learn SQL as their first language, especially for data analysis, reporting and testing. Knowing a language such as Python helps later, when you call SQL from a program.