Toolmingo
Guides22 min read

SQL Tutorial for Beginners (2025): Learn SQL Step by Step

Complete SQL tutorial for absolute beginners. Learn SELECT, INSERT, UPDATE, DELETE, JOINs, aggregations, and more. Free guide with real examples.

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):

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 WHERE clauses you run often
  • Columns used in JOIN conditions
  • Columns used in ORDER BY on 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.

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools