SQL (Structured Query Language) is the language used to talk to databases. It powers almost every app you use — from social media to banking. This tutorial takes you from zero to writing real SQL queries, with no prior experience required.
What you'll learn
| Topic | What you'll be able to do |
|---|---|
| Setup | Install a database and run your first query |
| SELECT | Retrieve data from tables |
| Filtering | Use WHERE to find exactly what you need |
| Sorting & limiting | ORDER BY, LIMIT |
| Aggregations | COUNT, SUM, AVG, GROUP BY |
| JOINs | Combine data from multiple tables |
| INSERT / UPDATE / DELETE | Modify data |
| Table design | Create tables with proper data types |
| Projects | Build 3 real SQL exercises |
SQL dialect used: Standard SQL with SQLite examples (works in PostgreSQL, MySQL with minor changes)
Part 1 — What Is SQL?
SQL is how you query a relational database — a system that stores data in tables (rows and columns), like a spreadsheet that can be queried at scale.
Without SQL: read every file, loop through data, find matches manually
With SQL: SELECT * FROM users WHERE country = 'UK' ← done in milliseconds
| SQL use case | Example |
|---|---|
| Web apps | Store users, posts, orders |
| Analytics | "How many sales last month?" |
| Data science | Query datasets, joins, aggregations |
| Admin dashboards | Reports, filters, exports |
| APIs | Every GET/POST endpoint usually runs SQL |
| Finance | Transactions, ledgers, reporting |
SQL vs NoSQL
| SQL (relational) | NoSQL | |
|---|---|---|
| Data shape | Tables (rows + columns) | Documents, key-value, graphs |
| Schema | Fixed (defined upfront) | Flexible |
| Query language | SQL | Database-specific |
| Examples | PostgreSQL, MySQL, SQLite, SQL Server | MongoDB, Redis, Cassandra |
| Best for | Structured data, complex queries | Unstructured/flexible data at scale |
For most apps — SQL is the right starting point.
Part 2 — Setup
Option A: SQLite (easiest, no install)
SQLite stores your database in a single file. Perfect for learning.
Online (no install at all):
- sqliteonline.com — run SQL in your browser
Local:
# Mac / Linux — SQLite usually pre-installed
sqlite3 mydb.db
# Windows — download from sqlite.org/download.html
# Or install DB Browser for SQLite (GUI)
Option B: PostgreSQL (most used in production)
# Mac
brew install postgresql@16
brew services start postgresql@16
psql postgres
# Ubuntu
sudo apt install postgresql
sudo -u postgres psql
# Windows — download from postgresql.org
Option C: MySQL
# Mac
brew install mysql
brew services start mysql
mysql -u root
# Ubuntu
sudo apt install mysql-server
sudo mysql
GUI tools (recommended for beginners)
| Tool | Databases | Free? |
|---|---|---|
| DB Browser for SQLite | SQLite | Yes |
| TablePlus | All major DBs | Free tier |
| DBeaver | All major DBs | Yes |
| pgAdmin | PostgreSQL | Yes |
| MySQL Workbench | MySQL | Yes |
Part 3 — Your First Database
3.1 Create a table
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
age INTEGER,
country TEXT
);
Each column has a name and a data type.
Common data types
| Type | Stores | Example |
|---|---|---|
INTEGER / INT |
Whole numbers | 42, -5, 0 |
REAL / FLOAT |
Decimal numbers | 3.14, 99.99 |
TEXT / VARCHAR |
Text strings | 'Alice', 'UK' |
BOOLEAN |
True / false | TRUE, FALSE |
DATE |
Calendar date | '2025-01-15' |
TIMESTAMP |
Date + time | '2025-01-15 14:30:00' |
BLOB |
Binary data | images, files |
3.2 Insert rows
INSERT INTO users (name, email, age, country)
VALUES ('Alice', 'alice@example.com', 28, 'UK');
INSERT INTO users (name, email, age, country)
VALUES ('Bob', 'bob@example.com', 35, 'USA');
INSERT INTO users (name, email, age, country)
VALUES ('Charlie', 'charlie@example.com', 22, 'Canada');
INSERT INTO users (name, email, age, country)
VALUES ('Diana', 'diana@example.com', 31, 'UK');
INSERT INTO users (name, email, age, country)
VALUES ('Eve', 'eve@example.com', 25, 'Australia');
3.3 View all rows
SELECT * FROM users;
Output:
id | name | email | age | country
---+---------+-------------------------+-----+-----------
1 | Alice | alice@example.com | 28 | UK
2 | Bob | bob@example.com | 35 | USA
3 | Charlie | charlie@example.com | 22 | Canada
4 | Diana | diana@example.com | 31 | UK
5 | Eve | eve@example.com | 25 | Australia
Part 4 — SELECT: Retrieve Data
SELECT is the most important SQL keyword. You'll use it constantly.
4.1 Select specific columns
-- All columns
SELECT * FROM users;
-- Specific columns only
SELECT name, email FROM users;
-- With alias (rename column in output)
SELECT name AS full_name, age AS years FROM users;
4.2 WHERE — filter rows
-- Equal
SELECT * FROM users WHERE country = 'UK';
-- Not equal
SELECT * FROM users WHERE country != 'USA';
-- Greater than / less than
SELECT * FROM users WHERE age > 25;
SELECT * FROM users WHERE age >= 28 AND age <= 35;
-- BETWEEN (inclusive)
SELECT * FROM users WHERE age BETWEEN 25 AND 30;
-- NULL check
SELECT * FROM users WHERE age IS NULL;
SELECT * FROM users WHERE age IS NOT NULL;
4.3 WHERE with text patterns
-- Starts with 'A'
SELECT * FROM users WHERE name LIKE 'A%';
-- Ends with 'e'
SELECT * FROM users WHERE name LIKE '%e';
-- Contains 'li'
SELECT * FROM users WHERE name LIKE '%li%';
-- Case-insensitive (PostgreSQL)
SELECT * FROM users WHERE name ILIKE 'alice';
4.4 IN — match a list of values
-- Country is UK or Canada
SELECT * FROM users WHERE country IN ('UK', 'Canada');
-- Country is NOT UK or Canada
SELECT * FROM users WHERE country NOT IN ('UK', 'Canada');
4.5 AND / OR / NOT
-- Both conditions must be true
SELECT * FROM users WHERE age > 25 AND country = 'UK';
-- Either condition is true
SELECT * FROM users WHERE age < 25 OR country = 'UK';
-- Negate condition
SELECT * FROM users WHERE NOT country = 'USA';
4.6 ORDER BY — sort results
-- Ascending (default)
SELECT * FROM users ORDER BY age;
SELECT * FROM users ORDER BY age ASC;
-- Descending
SELECT * FROM users ORDER BY age DESC;
-- Multiple sort columns
SELECT * FROM users ORDER BY country ASC, age DESC;
4.7 LIMIT — cap results
-- First 3 rows
SELECT * FROM users LIMIT 3;
-- Skip 2, then take 3 (pagination)
SELECT * FROM users LIMIT 3 OFFSET 2;
Part 5 — Aggregations
Aggregations compute summary values across multiple rows.
5.1 Basic aggregate functions
| Function | Returns |
|---|---|
COUNT(*) |
Number of rows |
COUNT(column) |
Non-NULL values in column |
SUM(column) |
Total of numeric column |
AVG(column) |
Average of numeric column |
MIN(column) |
Smallest value |
MAX(column) |
Largest value |
-- Total users
SELECT COUNT(*) FROM users;
-- Average age
SELECT AVG(age) FROM users;
-- Oldest user's age
SELECT MAX(age) FROM users;
-- Users with an age recorded
SELECT COUNT(age) FROM users;
5.2 GROUP BY — aggregate per group
-- Count users per country
SELECT country, COUNT(*) AS user_count
FROM users
GROUP BY country;
-- Average age per country
SELECT country, AVG(age) AS avg_age
FROM users
GROUP BY country
ORDER BY avg_age DESC;
Output of first query:
country | user_count
-----------+-----------
Australia | 1
Canada | 1
UK | 2
USA | 1
5.3 HAVING — filter groups
WHERE filters rows before grouping. HAVING filters groups after.
-- Countries with more than 1 user
SELECT country, COUNT(*) AS user_count
FROM users
GROUP BY country
HAVING COUNT(*) > 1;
-- Countries where average age > 27
SELECT country, AVG(age) AS avg_age
FROM users
GROUP BY country
HAVING AVG(age) > 27;
5.4 WHERE vs HAVING
| WHERE | HAVING | |
|---|---|---|
| Runs | Before GROUP BY | After GROUP BY |
| Filters | Individual rows | Groups |
| Can use aggregates? | No | Yes |
-- Correct: WHERE for row filter, HAVING for group filter
SELECT country, COUNT(*) AS cnt
FROM users
WHERE age IS NOT NULL -- filter rows first
GROUP BY country
HAVING COUNT(*) > 1; -- then filter groups
Part 6 — JOINs: Combine Tables
Most databases have multiple related tables. JOINs let you combine them.
6.1 Setup: a second table
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
product TEXT NOT NULL,
amount REAL NOT NULL,
order_date DATE NOT NULL
);
INSERT INTO orders (user_id, product, amount, order_date) VALUES
(1, 'Laptop', 999.99, '2025-01-10'),
(1, 'Mouse', 29.99, '2025-01-15'),
(2, 'Keyboard', 79.99, '2025-01-12'),
(4, 'Monitor', 349.99, '2025-01-20'),
(4, 'Webcam', 59.99, '2025-01-21');
-- Note: user 3 (Charlie) and 5 (Eve) have no orders
6.2 INNER JOIN — only matching rows
Returns rows where the join condition matches in BOTH tables.
SELECT users.name, orders.product, orders.amount
FROM users
INNER JOIN orders ON users.id = orders.user_id;
Output (only users who have orders):
name | product | amount
------+----------+--------
Alice | Laptop | 999.99
Alice | Mouse | 29.99
Bob | Keyboard | 79.99
Diana | Monitor | 349.99
Diana | Webcam | 59.99
6.3 LEFT JOIN — all left rows + matches
Returns ALL rows from the left table. If no match in right table, NULLs fill in.
SELECT users.name, orders.product, orders.amount
FROM users
LEFT JOIN orders ON users.id = orders.user_id;
Output:
name | product | amount
--------+----------+--------
Alice | Laptop | 999.99
Alice | Mouse | 29.99
Bob | Keyboard | 79.99
Charlie | NULL | NULL ← no orders
Diana | Monitor | 349.99
Diana | Webcam | 59.99
Eve | NULL | NULL ← no orders
6.4 Join types summary
| Join type | Returns |
|---|---|
INNER JOIN |
Only rows matching in BOTH tables |
LEFT JOIN |
All left rows + matching right rows (NULL if no match) |
RIGHT JOIN |
All right rows + matching left rows (NULL if no match) |
FULL OUTER JOIN |
All rows from both tables (NULL where no match) |
CROSS JOIN |
Every combination of rows (cartesian product) |
6.5 Practical JOIN examples
-- Find users with no orders
SELECT users.name
FROM users
LEFT JOIN orders ON users.id = orders.user_id
WHERE orders.id IS NULL;
-- Total spent per user
SELECT users.name, SUM(orders.amount) AS total_spent
FROM users
INNER JOIN orders ON users.id = orders.user_id
GROUP BY users.name
ORDER BY total_spent DESC;
-- Most popular products by revenue
SELECT product, COUNT(*) AS times_ordered, SUM(amount) AS revenue
FROM orders
GROUP BY product
ORDER BY revenue DESC;
6.6 Table aliases (cleaner queries)
-- Instead of writing full table names:
SELECT u.name, o.product, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.country = 'UK';
Part 7 — Modify Data
7.1 INSERT
-- Single row
INSERT INTO users (name, email, age, country)
VALUES ('Frank', 'frank@example.com', 29, 'Germany');
-- Multiple rows at once
INSERT INTO users (name, email, age, country)
VALUES
('Grace', 'grace@example.com', 33, 'France'),
('Henry', 'henry@example.com', 27, 'Japan');
7.2 UPDATE
-- Update one column for one row
UPDATE users SET age = 29 WHERE id = 1;
-- Update multiple columns
UPDATE users SET age = 36, country = 'UK' WHERE id = 2;
-- Update all rows in a group
UPDATE users SET country = 'United Kingdom' WHERE country = 'UK';
Always include WHERE with UPDATE — without it, you update every row.
-- ⚠️ Updates EVERY user's age
UPDATE users SET age = 25;
-- ✅ Updates only Alice
UPDATE users SET age = 29 WHERE name = 'Alice';
7.3 DELETE
-- Delete one row
DELETE FROM users WHERE id = 3;
-- Delete matching rows
DELETE FROM users WHERE country = 'Canada';
-- Delete all rows (keep table structure)
DELETE FROM users;
Always include WHERE with DELETE — without it, you delete all rows.
7.4 TRUNCATE vs DELETE vs DROP
| Command | What it does | Rollback? |
|---|---|---|
DELETE FROM t WHERE … |
Remove matching rows | Yes (most DBs) |
DELETE FROM t |
Remove all rows (slow) | Yes |
TRUNCATE TABLE t |
Remove all rows (fast) | Depends on DB |
DROP TABLE t |
Remove table entirely | No |
Part 8 — Table Design
8.1 Primary keys
Every table should have a primary key — a column (or combination) that uniquely identifies each row.
-- Auto-incrementing integer (SQLite)
CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
price REAL NOT NULL
);
-- PostgreSQL / MySQL
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
price DECIMAL(10, 2) NOT NULL
);
8.2 Foreign keys
A foreign key links one table to another, enforcing referential integrity.
CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
product TEXT NOT NULL,
amount REAL NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
-- Can't insert an order for a user that doesn't exist
);
8.3 Constraints
| Constraint | Meaning |
|---|---|
NOT NULL |
Column must have a value |
UNIQUE |
Value must be unique across all rows |
PRIMARY KEY |
NOT NULL + UNIQUE — identifies each row |
FOREIGN KEY |
Value must exist in another table |
DEFAULT |
Use this value if none provided |
CHECK |
Value must satisfy a condition |
CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
price REAL NOT NULL CHECK (price >= 0),
stock INTEGER NOT NULL DEFAULT 0,
sku TEXT UNIQUE
);
8.4 Indexes
Indexes speed up queries on large tables. The database creates a sorted lookup structure.
-- Create index on frequently queried column
CREATE INDEX idx_users_country ON users(country);
-- Unique index (like UNIQUE constraint)
CREATE UNIQUE INDEX idx_users_email ON users(email);
-- Composite index (both columns searched together)
CREATE INDEX idx_orders_user_date ON orders(user_id, order_date);
-- View indexes (PostgreSQL)
SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'users';
-- Drop index
DROP INDEX idx_users_country;
When to add an index:
- Columns in
WHEREclauses you run often - Columns used in
JOINconditions - Columns used in
ORDER BYon large tables
When NOT to add indexes:
- Small tables (full scan is fast enough)
- Columns you update constantly (indexes slow writes)
- Low-cardinality columns (e.g., boolean — only 2 values)
Part 9 — Useful SQL Features
9.1 DISTINCT — remove duplicates
-- All unique countries
SELECT DISTINCT country FROM users;
-- Count distinct countries
SELECT COUNT(DISTINCT country) AS num_countries FROM users;
9.2 CASE — conditional logic
SELECT name, age,
CASE
WHEN age < 25 THEN 'Young'
WHEN age < 35 THEN 'Mid'
ELSE 'Senior'
END AS age_group
FROM users;
9.3 Subqueries
A query inside another query.
-- Users older than average
SELECT name, age FROM users
WHERE age > (SELECT AVG(age) FROM users);
-- Users who placed at least one order
SELECT name FROM users
WHERE id IN (SELECT DISTINCT user_id FROM orders);
9.4 CTEs (Common Table Expressions)
Named subqueries using WITH — much more readable than nested subqueries.
-- Same as subquery above, but clearer
WITH order_users AS (
SELECT DISTINCT user_id FROM orders
)
SELECT name FROM users
WHERE id IN (SELECT user_id FROM order_users);
-- Multi-step CTE
WITH user_totals AS (
SELECT user_id, SUM(amount) AS total
FROM orders
GROUP BY user_id
),
top_spenders AS (
SELECT user_id, total
FROM user_totals
WHERE total > 200
)
SELECT u.name, ts.total
FROM users u
JOIN top_spenders ts ON u.id = ts.user_id
ORDER BY ts.total DESC;
9.5 NULL handling
NULL means "no value". It behaves differently than 0 or empty string.
-- NULL comparisons always return NULL (not TRUE/FALSE)
SELECT * FROM users WHERE age = NULL; -- returns nothing!
SELECT * FROM users WHERE age IS NULL; -- correct
-- COALESCE: return first non-NULL value
SELECT name, COALESCE(age, 0) AS age FROM users;
-- NULLIF: return NULL if two values are equal
SELECT NULLIF(amount, 0) FROM orders; -- NULL instead of 0
-- IFNULL (SQLite/MySQL) / ISNULL (SQL Server)
SELECT IFNULL(age, 'unknown') FROM users;
9.6 String functions
-- Concatenate
SELECT first_name || ' ' || last_name AS full_name FROM users; -- SQLite
SELECT CONCAT(first_name, ' ', last_name) AS full_name FROM users; -- MySQL/PostgreSQL
-- Length
SELECT name, LENGTH(name) AS name_length FROM users;
-- Upper / lower case
SELECT UPPER(name), LOWER(email) FROM users;
-- Trim whitespace
SELECT TRIM(' hello '); -- 'hello'
SELECT LTRIM(' hello '); -- 'hello '
SELECT RTRIM(' hello '); -- ' hello'
-- Substring
SELECT SUBSTR(name, 1, 3) FROM users; -- SQLite
SELECT SUBSTRING(name, 1, 3) FROM users; -- MySQL/PostgreSQL
-- Replace
SELECT REPLACE(email, '@example.com', '@newdomain.com') FROM users;
9.7 Date functions
-- Current date/time
SELECT DATE('now'); -- SQLite
SELECT CURRENT_DATE; -- Standard SQL
SELECT NOW(); -- MySQL/PostgreSQL
-- Date math
SELECT DATE('now', '+7 days'); -- SQLite: 7 days from now
SELECT CURRENT_DATE + 7; -- PostgreSQL
-- Extract parts
SELECT strftime('%Y', order_date) AS year FROM orders; -- SQLite
SELECT EXTRACT(YEAR FROM order_date) AS year FROM orders; -- PostgreSQL/MySQL
-- Orders in 2025
SELECT * FROM orders WHERE strftime('%Y', order_date) = '2025';
Part 10 — 3 Projects
Project 1: Library Database
Design and query a simple library system.
-- Tables
CREATE TABLE books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author TEXT NOT NULL,
published INTEGER,
available INTEGER DEFAULT 1 -- 1 = available, 0 = borrowed
);
CREATE TABLE members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
);
CREATE TABLE loans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id INTEGER NOT NULL REFERENCES books(id),
member_id INTEGER NOT NULL REFERENCES members(id),
loaned_on DATE NOT NULL,
returned_on DATE
);
-- Seed data
INSERT INTO books (title, author, published) VALUES
('The Pragmatic Programmer', 'David Thomas', 1999),
('Clean Code', 'Robert Martin', 2008),
('You Don''t Know JS', 'Kyle Simpson', 2015),
('Designing Data-Intensive Applications', 'Martin Kleppmann', 2017),
('The Algorithm Design Manual', 'Steven Skiena', 2008);
INSERT INTO members (name, email) VALUES
('Alice', 'alice@lib.com'),
('Bob', 'bob@lib.com');
INSERT INTO loans (book_id, member_id, loaned_on, returned_on) VALUES
(1, 1, '2025-01-01', '2025-01-15'),
(2, 1, '2025-01-16', NULL),
(3, 2, '2025-01-05', '2025-01-20');
-- Update availability
UPDATE books SET available = 0 WHERE id = 2;
Queries to try:
-- Books currently on loan
SELECT b.title, m.name AS borrowed_by, l.loaned_on
FROM loans l
JOIN books b ON l.book_id = b.id
JOIN members m ON l.member_id = m.id
WHERE l.returned_on IS NULL;
-- Books never borrowed
SELECT b.title FROM books b
LEFT JOIN loans l ON b.id = l.book_id
WHERE l.id IS NULL;
-- Most popular books
SELECT b.title, COUNT(l.id) AS times_loaned
FROM books b
LEFT JOIN loans l ON b.id = l.book_id
GROUP BY b.title
ORDER BY times_loaned DESC;
-- Member borrowing history
SELECT m.name, b.title, l.loaned_on, l.returned_on
FROM loans l
JOIN members m ON l.member_id = m.id
JOIN books b ON l.book_id = b.id
WHERE m.name = 'Alice'
ORDER BY l.loaned_on DESC;
Project 2: E-commerce Sales Analysis
CREATE TABLE categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE products (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
category_id INTEGER REFERENCES categories(id),
price REAL NOT NULL
);
CREATE TABLE sales (
id INTEGER PRIMARY KEY,
product_id INTEGER REFERENCES products(id),
quantity INTEGER NOT NULL,
sale_date DATE NOT NULL
);
-- Seed
INSERT INTO categories VALUES (1,'Electronics'),(2,'Books'),(3,'Clothing');
INSERT INTO products VALUES
(1,'Laptop', 1, 999.99),
(2,'Headphones',1, 79.99),
(3,'SQL Basics',2, 29.99),
(4,'T-Shirt', 3, 19.99),
(5,'Tablet', 1, 499.99);
INSERT INTO sales (product_id, quantity, sale_date) VALUES
(1, 5, '2025-01-05'), (2, 12, '2025-01-07'),
(3, 30, '2025-01-10'),(4, 25, '2025-01-12'),
(5, 8, '2025-01-15'), (1, 3, '2025-02-01'),
(2, 7, '2025-02-05'), (3, 15, '2025-02-10');
Analysis queries:
-- Total revenue per product
SELECT p.name,
SUM(s.quantity) AS units_sold,
SUM(s.quantity * p.price) AS revenue
FROM sales s
JOIN products p ON s.product_id = p.id
GROUP BY p.name
ORDER BY revenue DESC;
-- Revenue per category
SELECT c.name AS category,
SUM(s.quantity * p.price) AS revenue
FROM sales s
JOIN products p ON s.product_id = p.id
JOIN categories c ON p.category_id = c.id
GROUP BY c.name
ORDER BY revenue DESC;
-- Monthly revenue
SELECT strftime('%Y-%m', sale_date) AS month,
SUM(s.quantity * p.price) AS monthly_revenue
FROM sales s
JOIN products p ON s.product_id = p.id
GROUP BY month
ORDER BY month;
-- Best-selling product each month
WITH monthly_sales AS (
SELECT strftime('%Y-%m', s.sale_date) AS month,
p.name,
SUM(s.quantity) AS units,
ROW_NUMBER() OVER (
PARTITION BY strftime('%Y-%m', s.sale_date)
ORDER BY SUM(s.quantity) DESC
) AS rank
FROM sales s
JOIN products p ON s.product_id = p.id
GROUP BY month, p.name
)
SELECT month, name, units
FROM monthly_sales
WHERE rank = 1;
Project 3: Employee Directory
CREATE TABLE departments (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE employees (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
department_id INTEGER REFERENCES departments(id),
manager_id INTEGER REFERENCES employees(id),
salary REAL NOT NULL,
hire_date DATE NOT NULL
);
INSERT INTO departments VALUES (1,'Engineering'),(2,'Sales'),(3,'HR');
INSERT INTO employees VALUES
(1,'Sarah', 1, NULL, 120000,'2019-03-01'), -- CEO of Eng
(2,'Mike', 1, 1, 95000,'2020-06-15'),
(3,'Emma', 1, 1, 88000,'2021-01-10'),
(4,'David', 2, NULL, 80000,'2018-11-20'), -- CEO of Sales
(5,'Lisa', 2, 4, 72000,'2022-04-05'),
(6,'James', 3, NULL, 75000,'2017-07-12'), -- CEO of HR
(7,'Anna', 3, 6, 68000,'2023-02-28');
Queries:
-- Employees with their manager's name
SELECT e.name AS employee, m.name AS manager, d.name AS department
FROM employees e
JOIN departments d ON e.department_id = d.id
LEFT JOIN employees m ON e.manager_id = m.id
ORDER BY d.name, e.name;
-- Average salary per department
SELECT d.name, AVG(e.salary) AS avg_salary, COUNT(*) AS headcount
FROM employees e
JOIN departments d ON e.department_id = d.id
GROUP BY d.name;
-- Employees earning more than their department average
SELECT e.name, e.salary, d.name AS department
FROM employees e
JOIN departments d ON e.department_id = d.id
WHERE e.salary > (
SELECT AVG(salary) FROM employees
WHERE department_id = e.department_id
)
ORDER BY department, e.salary DESC;
-- Who has been at the company the longest?
SELECT name, hire_date,
CAST((julianday('now') - julianday(hire_date)) / 365 AS INTEGER) AS years_at_company
FROM employees
ORDER BY hire_date ASC;
Part 11 — SQL Cheat Sheet
SELECT syntax
SELECT [DISTINCT] column1, column2, ...
FROM table_name
[JOIN other_table ON condition]
[WHERE condition]
[GROUP BY column]
[HAVING condition]
[ORDER BY column [ASC|DESC]]
[LIMIT n OFFSET m];
Order of execution (not the same as writing order):
1. FROM / JOIN — which table(s)?
2. WHERE — filter rows
3. GROUP BY — create groups
4. HAVING — filter groups
5. SELECT — pick columns / compute
6. DISTINCT — deduplicate
7. ORDER BY — sort
8. LIMIT / OFFSET — paginate
Quick reference table
| Task | SQL |
|---|---|
| Get all rows | SELECT * FROM t |
| Get specific columns | SELECT a, b FROM t |
| Filter | WHERE age > 25 |
| Pattern match | WHERE name LIKE 'A%' |
| Multiple values | WHERE country IN ('UK', 'US') |
| Sort | ORDER BY age DESC |
| Limit | LIMIT 10 OFFSET 20 |
| Count rows | SELECT COUNT(*) FROM t |
| Sum | SELECT SUM(amount) FROM t |
| Average | SELECT AVG(price) FROM t |
| Group | GROUP BY country |
| Filter group | HAVING COUNT(*) > 1 |
| Remove dupes | SELECT DISTINCT country FROM t |
| Combine tables | JOIN t2 ON t.id = t2.fk |
| Add row | INSERT INTO t (a, b) VALUES (1, 2) |
| Update row | UPDATE t SET a = 1 WHERE id = 5 |
| Delete row | DELETE FROM t WHERE id = 5 |
| Create table | CREATE TABLE t (id INT PRIMARY KEY, ...) |
| Add index | CREATE INDEX i ON t(col) |
Part 12 — Common Mistakes
| Mistake | Problem | Fix |
|---|---|---|
WHERE age = NULL |
Always returns nothing | Use WHERE age IS NULL |
UPDATE without WHERE |
Updates every row | Always add WHERE |
DELETE without WHERE |
Deletes every row | Always add WHERE |
Comparing with = instead of IS for NULL |
Wrong results | Use IS NULL / IS NOT NULL |
SELECT * in production |
Slow, fragile | Select only needed columns |
| No index on JOIN columns | Full table scans | Add index on foreign key columns |
Using HAVING instead of WHERE for row filters |
Slower — filters after grouping | WHERE filters before grouping (faster) |
| Forgetting single quotes around strings | Syntax error | WHERE name = 'Alice' (not "Alice") |
Part 13 — Learning Path
| Stage | Topic | Time |
|---|---|---|
| 1 | SELECT, WHERE, ORDER BY, LIMIT | Week 1 |
| 2 | Aggregations: COUNT, SUM, AVG, GROUP BY, HAVING | Week 1-2 |
| 3 | JOINs: INNER, LEFT, RIGHT, FULL | Week 2 |
| 4 | INSERT, UPDATE, DELETE, table design | Week 3 |
| 5 | Subqueries, CTEs, NULL handling | Week 3-4 |
| 6 | Indexes, EXPLAIN, performance basics | Week 4-5 |
| 7 | Window functions (ROW_NUMBER, RANK, LAG) | Week 5-6 |
| 8 | Transactions, ACID, isolation levels | Week 6-7 |
| 9 | PostgreSQL / MySQL specifics | Week 7-8 |
| 10 | Practice on real datasets (Kaggle, LeetCode SQL) | Ongoing |
Where to practice
| Resource | Type | Free? |
|---|---|---|
| sqlzoo.net | Interactive exercises | Yes |
| leetcode.com/problemset/database | 200+ SQL problems | Yes (some paid) |
| mode.com/sql-tutorial | Analytics-focused | Yes |
| pgexercises.com | PostgreSQL exercises | Yes |
| w3schools.com/sql | Reference + try it | Yes |
| sqliteonline.com | Run SQL in browser | Yes |
SQL vs related terms
| Term | What it is |
|---|---|
| SQL | Language to query/modify relational databases |
| MySQL | Open-source SQL database server |
| PostgreSQL | Advanced open-source SQL database |
| SQLite | Serverless SQL database (file-based) |
| SQL Server | Microsoft's SQL database |
| Oracle DB | Enterprise SQL database by Oracle |
| NoSQL | Non-relational database (MongoDB, Redis, etc.) |
| ORM | Library that generates SQL from code (Prisma, SQLAlchemy) |
| Schema | Definition of tables and columns |
| Query | A SQL command sent to the database |
FAQ
Q: Do I need to memorize all SQL commands?
You need to know SELECT, WHERE, JOIN, GROUP BY, INSERT, UPDATE, DELETE fluently. The rest — look up as needed. After a few projects, most syntax becomes muscle memory.
Q: Which database should I start with?
SQLite for learning (no server, single file). PostgreSQL for serious projects — it's the most feature-rich open-source option and widely used in production.
Q: Is SQL a programming language?
It's a query language — declarative rather than procedural. You describe what data you want, not how to get it. Most people use SQL alongside a general-purpose language (Python, JavaScript, etc.).
Q: How long to learn SQL?
Basic SELECT queries in a few hours. Comfortable with JOINs and aggregations in 1-2 weeks of practice. Production-ready in 1-2 months. SQL is one of the fastest-to-learn technical skills.
Q: Do I need to know SQL to use a database ORM?
Yes. ORMs (like Prisma, SQLAlchemy) generate SQL for you, but when queries are wrong or slow, you need SQL to debug and optimize. Don't skip SQL even if you use an ORM.
Q: What's the difference between MySQL and PostgreSQL?
Both are excellent. MySQL is simpler and very popular with PHP/WordPress. PostgreSQL has more features (JSON support, full-text search, extensions like pgvector). For new projects, PostgreSQL is generally the better choice.