Quick Start: Build a two-table database with sqlite3, then filter, join, group, and rank its rows. The hands-on work takes about 30 minutes, with no server to install. To try the queries first, use the Practice SQL section, which runs them in your browser.
SQL looks like English, which is why it fools people. SELECT name FROM customers WHERE city = NULL reads fine and returns nothing. A LEFT JOIN with one extra condition in the WHERE clause drops the customers you meant to keep, with no error. A report that took two seconds last spring takes two minutes now, and nothing in the query text tells you why.
A handful of concepts explain all three failures.
What You’ll Learn
- What is SQL, and what problem does it solve?
- What are the use cases for SQL, and when is it the wrong tool?
- In what order does a database evaluate the clauses of a query?
- How do you filter, join, group, and rank rows?
- Why does
NULLbreak comparisons, and how do you handle it? - How do you read a query plan and make a slow query fast?
- Where can you go to learn more about SQL?
The Basics
SQL (Structured Query Language) is the language relational databases speak. You describe the rows you want, and the database decides how to fetch them. You write what you want, and a component called the query planner picks how: which indexes to use and in what order to join tables.
Every query in this guide runs in sqlite3, the same shell as the Practice SQL section. SELECT, JOIN, GROUP BY, and window functions look similar in other engines, but the setup, plans, errors, and indexes below are SQLite’s.
This guide covers the 20% of SQL behind 80% of daily work. Execution order, filtering, NULL handling, joins, aggregation, common table expressions (CTEs), window functions, safe updates, and query plans are the core. The resources at the end cover schema design, normalization, and moving the same ideas onto a server database later.
Primary Use Cases
- Answer questions about application data: who signed up, what sold, what failed.
- Build reports and dashboards from a database or data warehouse.
- Transform data in pipelines with tools such as dbt, Spark SQL, or DuckDB.
- Read and change data from application code through a driver or an object-relational mapper (ORM).
- Debug production issues by inspecting the rows an application wrote.
Less Suitable Use Cases
- Deeply recursive graph traversal, such as friends-of-friends at many hops.
WITH RECURSIVEhandles a few hops, but a graph database fits better as the depth grows. - Free-text search ranking. A search engine such as OpenSearch fits better.
- Step-by-step procedural logic with many branches. Application code is easier to test and read.
When to Use SQL
Reach for SQL whenever your data lives in tables and your question is about sets of rows: filter these, match those, count the rest. If you find yourself pulling every row into Python to loop over it, the database can almost always do that work faster and closer to the data.
The 20% That Does 80%
Every example below runs against the same small database, so set it up first.
Set up a practice database
You’ll need:
sqlite3, the SQLite command-line shell.- A terminal and a text editor.
- No prior SQL knowledge.
macOS ships sqlite3. On Windows, download the command-line tools from the SQLite website, sqlite.org. On Debian or Ubuntu, install it from your terminal:
Runs on your machine, not in the practice shell
sudo apt install sqlite3The output below comes from SQLite 3.54.1; other versions can word query plans slightly differently, and versions before 3.38 omit the BLOOM FILTER line in Step 2. Older versions left-align numbers in .mode column output; the values match. Window functions need SQLite 3.25 or later, so check your version from your terminal:
Runs on your machine, not in the practice shell
sqlite3 --versionSave this as shop.sql:
CREATE TABLE customers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
city TEXT
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
ordered_on TEXT NOT NULL,
total REAL NOT NULL
);
INSERT INTO customers (id, name, city) VALUES
(1, 'Ada', 'Portland'),
(2, 'Grace', 'Seattle'),
(3, 'Linus', 'Portland'),
(4, 'Edsger', NULL);
INSERT INTO orders (id, customer_id, ordered_on, total) VALUES
(1, 1, '2026-09-02', 42.50),
(2, 1, '2026-09-15', 18.00),
(3, 2, '2026-09-20', 99.99),
(4, 3, '2026-10-01', 12.25),
(5, 1, '2026-10-03', 61.00),
(6, 2, '2026-10-07', 7.50);Load it and open the shell:
Runs on your machine, not in the practice shell
sqlite3 shop.db < shop.sql
sqlite3 shop.dbTo start over, leave the shell with .quit, then delete the database and load it again from your terminal:
Runs on your machine, not in the practice shell
rm shop.db && sqlite3 shop.db < shop.sqlTurn on readable output inside the shell:
.headers on
.mode columnSQLite has no separate date type, so the dates are ISO 8601 text, which sorts correctly as plain strings. Edsger has no city and no orders. Both gaps are on purpose.
What you get: a shop.db file with four customers and six orders, and a prompt that reads sqlite>.
Read a query in execution order
You write SELECT first, but the database logically evaluates it near the end: FROM and JOIN, then WHERE, GROUP BY, HAVING, SELECT, ORDER BY, and finally LIMIT:
This order explains a whole class of errors. WHERE runs before GROUP BY, so it can’t filter on SUM(total); that’s the job of HAVING. SELECT runs after WHERE, so an alias defined in SELECT is not part of the standard language in WHERE. SQLite accepts that alias as an extension. Write the expression twice, or name it in a CTE and filter in the outer query, so the same SQL still means the same thing when you leave sqlite3. The order describes meaning, not the physical steps: SQLite reorders work freely when the result stays the same.
What you get: a way to predict what each clause can see before you run the query.
Filter, sort, and limit
SELECT name, city
FROM customers
WHERE city = 'Portland'
ORDER BY name;name city
----- --------
Ada Portland
Linus PortlandName the columns you need instead of writing SELECT *. Explicit columns survive schema changes, move less data, and let an index answer the query on its own. Add ORDER BY whenever order matters. Without it, rows come back in an unspecified order that depends on the plan.
What you get: exactly the rows and columns you asked for, in a guaranteed order.
Treat NULL as unknown
NULL means “unknown,” and comparing anything to unknown yields unknown, which WHERE treats as false:
SELECT name FROM customers WHERE city = NULL; -- returns no rows
SELECT name FROM customers WHERE city IS NULL; -- returns EdsgerUse IS NULL and IS NOT NULL, and use COALESCE(column, fallback) to swap in a default for display or math.
What you get: filters that find missing values instead of silently skipping them.
Join tables
A join matches rows from two tables on a condition. An inner JOIN keeps only matches:
SELECT c.name, o.ordered_on, o.total
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
ORDER BY o.ordered_on;name ordered_on total
----- ---------- -----
Ada 2026-09-02 42.5
Ada 2026-09-15 18.0
Grace 2026-09-20 99.99
Linus 2026-10-01 12.25
Ada 2026-10-03 61.0
Grace 2026-10-07 7.5Edsger disappears because Edsger has no orders. A LEFT JOIN keeps every row from the left table and fills the missing side with NULL:
SELECT c.name, COUNT(o.id) AS order_count
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY order_count DESC, c.name; name order_count
------ -----------
Ada 3
Grace 2
Linus 1
Edsger 0COUNT(o.id) counts only non-null values, so Edsger gets 0. COUNT(*) would count Edsger’s row and report 1. Group by c.id as well as c.name, so two customers who share a name stay separate.
What you get: answers that include the rows with no match, which is usually what a report needs.
Put outer-join filters in ON, not WHERE
Where you put a condition changes the result of a LEFT JOIN. Ask for each customer’s October orders with the filter in WHERE:
SELECT c.name, o.total
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
WHERE o.ordered_on >= '2026-10-01'
ORDER BY c.name;name total
----- -----
Ada 61.0
Grace 7.5
Linus 12.25Edsger vanished. WHERE runs after the join; Edsger’s ordered_on is NULL, and the comparison is unknown, so the row is dropped. The query became an inner join. Move the condition into ON:
SELECT c.name, o.total
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.id
AND o.ordered_on >= '2026-10-01'
ORDER BY c.name; name total
------ -----
Ada 61.0
Edsger
Grace 7.5
Linus 12.25For inner joins, the two placements return the same rows, and most planners produce the same plan. For outer joins, ON decides which rows match and WHERE decides which rows survive.
What you get: outer joins that keep the rows you meant to keep.
Group and aggregate
GROUP BY collapses rows into one row per group. Aggregates such as COUNT, SUM, AVG, MIN, and MAX summarize each group, and HAVING filters groups after aggregation:
SELECT c.name, COUNT(*) AS orders, SUM(o.total) AS spent
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
GROUP BY c.id, c.name
HAVING SUM(o.total) > 50
ORDER BY spent DESC;name orders spent
----- ------ ------
Ada 3 121.5
Grace 2 107.49Every column in SELECT must be either in GROUP BY or inside an aggregate. SQLite does not enforce that. Leave c.city out of GROUP BY and out of an aggregate, and SQLite still runs the query and fills city from an arbitrary row in the group. Group by c.id and every column you display, or wrap the extra column in MAX or MIN, so the result does not depend on which row SQLite picked.
What you get: totals per group, filtered on the totals themselves.
Name steps with CTEs and rank with window functions
A CTE, written with WITH, names an intermediate result so you can read a query top to bottom. A window function computes across related rows without collapsing them the way GROUP BY does. Together they answer “latest order per customer”:
WITH ranked AS (
SELECT c.name, o.ordered_on, o.total,
ROW_NUMBER() OVER (
PARTITION BY c.id
ORDER BY o.ordered_on DESC
) AS rn
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
)
SELECT name, ordered_on, total
FROM ranked
WHERE rn = 1
ORDER BY name;name ordered_on total
----- ---------- -----
Ada 2026-10-03 61.0
Grace 2026-10-07 7.5
Linus 2026-10-01 12.25PARTITION BY restarts the numbering for each customer. Drop it and add a running sum to see the other common pattern:
SELECT ordered_on, total,
SUM(total) OVER (ORDER BY ordered_on) AS running_total
FROM orders
ORDER BY ordered_on;ordered_on total running_total
---------- ----- -------------
2026-09-02 42.5 42.5
2026-09-15 18.0 60.5
2026-09-20 99.99 160.49
2026-10-01 12.25 172.74
2026-10-03 61.0 233.74
2026-10-07 7.5 241.24What you get: “top N per group” and running totals, two questions that are painful without window functions.
Change data inside a transaction
UPDATE and DELETE without a WHERE clause change every row in the table. Run the WHERE clause as a SELECT first to see exactly which rows you’re about to touch, then wrap the change in a transaction, check the result, and decide:
BEGIN;
UPDATE orders SET total = total * 0.9 WHERE customer_id = 1;
SELECT id, total FROM orders WHERE customer_id = 1;
ROLLBACK;id total
-- -----
1 38.25
2 16.2
5 54.9ROLLBACK undoes the discount, and COMMIT would keep it.
What you get: a chance to catch a bad WHERE clause before it changes data for good.
Read the query plan
EXPLAIN QUERY PLAN prints how SQLite intends to run a query. EXPLAIN without QUERY PLAN dumps the virtual-machine opcodes; skip that listing until you need it. The plan is a prediction, not a stopwatch. In desktop sqlite3, turn on .timer on after you have a plan you like, then run the query.
EXPLAIN QUERY PLAN
SELECT id, total FROM orders WHERE customer_id = 1;QUERY PLAN
`--SCAN ordersSCAN reads every row in the table. Add an index on the filtered column and ask again:
CREATE INDEX IF NOT EXISTS idx_orders_customer_id ON orders (customer_id);
EXPLAIN QUERY PLAN
SELECT id, total FROM orders WHERE customer_id = 1;QUERY PLAN
`--SEARCH orders USING INDEX idx_orders_customer_id (customer_id=?)SEARCH ... USING INDEX jumps straight to the matching rows. On six rows, the difference is invisible. On six million, a scan reads all six million to return a handful.
CREATE INDEX writes into the database file and holds a lock while it runs. On a file other connections also have open, build the index during a quiet window, or copy the file, index the copy, and swap it in.
Wrapping an indexed column in a function hides it from the index. Index the order date, then compare two ways to ask for October:
CREATE INDEX IF NOT EXISTS idx_orders_ordered_on ON orders (ordered_on);
EXPLAIN QUERY PLAN
SELECT id FROM orders WHERE substr(ordered_on, 1, 7) = '2026-10';
EXPLAIN QUERY PLAN
SELECT id FROM orders
WHERE ordered_on >= '2026-10-01' AND ordered_on < '2026-11-01';QUERY PLAN
`--SCAN orders USING COVERING INDEX idx_orders_ordered_on
QUERY PLAN
`--SEARCH orders USING COVERING INDEX idx_orders_ordered_on (ordered_on>? AND ordered_on<?)COVERING INDEX holds every column the query needs, so SQLite never reads the table itself. It does not narrow the search: SCAN still reads every index entry, and only SEARCH jumps to the matching ones.
The first query scans because the index stores ordered_on, not substr(ordered_on, 1, 7). The range version searches.
What you get: a way to tell whether the database searches or scans, before a slow query reaches production.
Practice SQL
Try every query in this article without installing anything. The practice below runs SQLite 3.54.1’s own shell in your browser, with shop.db already loaded and .headers on and .mode column set, so the output looks like the blocks above. Press Start, type or paste a query into the editor, and press Run. Choose Open full screen to give it the whole window.
The exercise list holds ten tasks drawn from the core sections, from filtering and sorting to reading a query plan and rolling back a change. Choose an exercise to put its starting query in the editor, change the query until it does the task, and press Check. Check runs your query and the task’s answer on fresh copies of the practice data and compares the results, not the SQL text, so a query written differently from the answer still passes when it gets the same result. Hint and Show answer teach in place when you get stuck, so Open full screen and the standalone page stay useful without jumping back here. Reset brings the practice data back.
Every example also works in your own sqlite3. The page has no terminal, no files, and no other programs, so a few things run only on your machine:
- Terminal lines.
sqlite3 shop.db < shop.sqlandrm shop.dbgive SQLite’s parse error. - Program and leave commands.
.shell,.system,.cd,.quit, and.exitare not in this build. - File-writing commands.
.once,.output,.excel, and.wwware not in this build, so the next query prints on the page. - File-reading commands.
.read,.import,.backup,.save,.restore,.archive,.clone, and.loadare not in this build. - Safe-mode command.
.nonceis not in this build either; it matters only for desktopsqlite3started with--safe.
Practice the commands on this page in real SQLite, right here. Nothing downloads until Start (about 1.4 MB).
Each exercise gives you a starting query. It runs and shows the data the task is about, but it does not do the task yet: change it until it does. Hint and Show answer stay on this page when you get stuck. Filter, sort, and limit 1 List the two largest orders, the biggest first Treat NULL as unknown 2 List the customers with no city on file Join tables 3 Show each order's id with the name of the customer who placed it 4 List the customers who have never ordered Put outer-join filters in ON, not WHERE 5 List every customer with the number of their orders over 50, zero included Group and aggregate 6 List the customers with two or more orders, with their order count and total spent, highest spend first Name steps with CTEs and rank with window functions 7 Rank each customer's orders from the largest to the smallest 8 Name the customers who spent more than the average customer Change data inside a transaction 9 Raise every Portland order by 10%, look at the change, then undo it Read the query plan 10 Make the lookup of one customer's orders use an index
The SQLite practice needs JavaScript. Every command in the reference also works in SQLite on your own machine.
Build Something: A Customer Spending Report
Combine the pieces into one report: every customer, their order count, total spend, last order date, and spend rank, including customers with no orders.
Step 1: Write the report query
Aggregate per customer in a CTE, then rank in the outer query. The LEFT JOIN keeps Edsger, COUNT(o.id) counts Edsger’s orders as zero, and COALESCE turns a NULL spend into 0.
WITH per_customer AS (
SELECT c.id, c.name,
COUNT(o.id) AS orders,
COALESCE(SUM(o.total), 0) AS spent,
MAX(o.ordered_on) AS last_order
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id
GROUP BY c.id, c.name
)
SELECT name, orders, spent, last_order,
RANK() OVER (ORDER BY spent DESC) AS spend_rank
FROM per_customer
ORDER BY spend_rank;Expected output:
name orders spent last_order spend_rank
------ ------ ------ ---------- ----------
Ada 3 121.5 2026-10-03 1
Grace 2 107.49 2026-10-07 2
Linus 1 12.25 2026-10-01 3
Edsger 0 0 4What you learned: GROUP BY runs first and collapses orders into one row per customer; the window function then ranks those rows without collapsing them further.
Step 2: Check the join in the plan
Make sure the index from the previous section exists. The statement is safe to run twice:
CREATE INDEX IF NOT EXISTS idx_orders_customer_id ON orders (customer_id);Ask for the plan of the join at the heart of the report:
EXPLAIN QUERY PLAN
SELECT c.name, o.total
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id;Expected output:
QUERY PLAN
|--SCAN c
`--SEARCH o USING INDEX idx_orders_customer_id (customer_id=?) LEFT-JOINDrop the index and ask again:
DROP INDEX idx_orders_customer_id;
EXPLAIN QUERY PLAN
SELECT c.name, o.total
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.id;QUERY PLAN
|--SCAN c
|--BLOOM FILTER ON o (customer_id=?)
`--SEARCH o USING AUTOMATIC COVERING INDEX (customer_id=?) LEFT-JOINAUTOMATIC COVERING INDEX builds a temporary index for this one query and throws it away afterward. A Bloom filter is a probabilistic check SQLite uses to avoid unnecessary row lookups during the join. It has no impact on correctness.
Both lines appear because the permanent index on orders.customer_id is gone. Recreate it:
CREATE INDEX idx_orders_customer_id ON orders (customer_id);What you learned: index the columns you join on, and treat an automatic index in a plan as a request for a permanent one.
Write Queries That Stay Fast
Fast queries come from three habits: skip work the result doesn’t need, let indexes do the searching, and shrink tables before you join them.
Skip unneeded work.
- Sort once, in the outermost query. Only the outermost
ORDER BYguarantees the order of the result, so anORDER BYinside a CTE or subquery adds a sort the final result can’t rely on. - Use
UNION ALLwhen duplicates can’t occur or don’t matter.UNIONremoves duplicate rows, which costs a sort or hash step;UNION ALLkeeps every row and skips it. - Write
DISTINCTonly when duplicates are possible. Reaching for it to hide extra rows usually signals a join that matches more rows than you expected; fix the join instead.
Let indexes search.
Filter on indexed columns, and compare the bare column, as the
ordered_onrange above shows.SQLite can use indexes for an
ORacross different columns only when each branch has an index of its own. Otherwise it scans the whole table, so check the plan before and after a rewrite. With both indexes from the previous section in place, the October-or-customer query searches twice:EXPLAIN QUERY PLAN SELECT id FROM orders WHERE customer_id = 1 OR ordered_on >= '2026-10-01';QUERY PLAN `--MULTI-INDEX OR |--INDEX 1 | `--SEARCH orders USING INDEX idx_orders_customer_id (customer_id=?) `--INDEX 2 `--SEARCH orders USING INDEX idx_orders_ordered_on (ordered_on>?)Drop either index and the plan becomes a
SCANoforders.After a large load, run
ANALYZEso SQLite records row counts. Without those stats, the planner guesses, and a plan that was cheap on six rows can stay aSCANafter the table grows.
Shrink before joining.
- Filter or aggregate in a CTE first, then join the smaller result. SQLite may materialize that CTE into a temporary table, or fold it into the parent query. Prefix the CTE with
MATERIALIZEDorNOT MATERIALIZEDwhen you need one or the other, then confirm withEXPLAIN QUERY PLAN.
Run EXPLAIN QUERY PLAN before and after each change, and keep the change only when the plan gets cheaper: a SEARCH in place of a SCAN, or a covering index in place of a table read. When the obvious fixes run out, bigger gains often come from reshaping the data, such as adding a summary table, so the query itself gets simpler.
Troubleshooting
Parse error near line 1: no such column: nme.
Check the spelling first. If the name is an alias from SELECT used in WHERE, SQLite may still accept it. Repeat the expression, or compute it in a CTE and filter in the outer query, so you are not relying on that extension.
ambiguous column name: id.
Both joined tables have an id column. Prefix it with the table alias: c.id or o.id.
A grouped query returns a city, or a name, you did not expect
A SELECT column is neither grouped nor aggregated. SQLite still runs the query and fills that column from an arbitrary row in the group. Add the column to GROUP BY, or wrap it in MAX or MIN.
A LEFT JOIN returns fewer rows than the left table
A condition on the right-hand table sits in WHERE. Move it into ON.
table customers already exists or UNIQUE constraint failed: customers.id
You loaded shop.sql into a database that already has the tables. Leave the shell with .quit, delete shop.db, and load it again: rm shop.db && sqlite3 shop.db < shop.sql.
A syntax error on a query that uses OVER.
Your sqlite3 predates window functions. Run sqlite3 --version and upgrade to 3.25 or later.
A filter on = NULL returns nothing
Use IS NULL.
A query got slow as the table grew
Run EXPLAIN QUERY PLAN. Look for a full SCAN on a large table, a missing index on a join column, or a function wrapped around an indexed column.
Learn SQL: Beyond the Basics
You’ve covered the core. Start with one structured course, practice daily on an interactive site, and keep a performance book nearby for the day a query gets slow.
Video
- CS50’s Introduction to Databases with SQL. Harvard’s free course: querying, relating, designing, writing, viewing, and optimizing, taught first in SQLite.
- SQL Tutorial: Full Database Course for Beginners. freeCodeCamp’s long-form course on YouTube, good for a weekend of steady practice.
Audio
- The Untold Story of SQLite. Adam Gordon Bell interviews D. Richard Hipp on CoRecursive: how SQLite started, and why a single-file database ships in so many products.
Books
- Learning SQL, 3rd Edition by Alan Beaulieu. A steady introduction; work the examples in
sqlite3. - SQL for Data Analysis by Cathy Tanimura. Analytics patterns: time series, cohorts, text, and anomaly detection.
- SQL Antipatterns, Volume 1 by Bill Karwin. Common schema and query mistakes, each with a fix. Read it after your first few months.
- SQL Performance Explained by Markus Winand. How indexes work, written for developers. Run the ideas against
EXPLAIN QUERY PLAN.
Online
- SQL As Understood By SQLite. The language reference for the engine this guide uses.
- Query Planning. How SQLite picks indexes, and how to read
EXPLAIN QUERY PLAN. - SQLBolt. Short interactive lessons with exercises in the browser.
- Select Star SQL. A free interactive book built around one real dataset.
- Interview Master. SQL interview practice from Dawn Choo’s team.
- DataLemur SQL Tutorial. A free tutorial aimed at data analysts and interview prep.
- Use The Index, Luke. Markus Winand’s free online companion on indexing.
- Become a SELECT star! by Julia Evans. An illustrated zine on how queries behave.
Related Content
- Fundamentals of Databases, for relational models, keys, and normalization.
- How to Use sqlite-utils, for loading data into SQLite from the command line.
- Learn Amazon Athena, for running SQL over files in Amazon S3.
- Fundamentals of Analytics Engineering, for testing and layering the SQL you write.

Comments #