SQL (Structured Query Language) is the standard language for working with relational databases. Whether you’re managing application data, generating reports, or analyzing business information, SQL gives you the tools to store, retrieve, and manipulate data efficiently.
This guide takes you from the basics to advanced SQL concepts in a practical, step-by-step way. Each section includes examples you can run immediately, so you can learn by doing. By following along, you’ll build a strong foundation and be ready to handle real-world database tasks confidently.
SQL (Structured Query Language) is the language we use to:
Almost every database — MySQL, PostgreSQL, SQLite, SQL Server — uses SQL with slight variations.
CREATE DATABASE shop;
Some playgrounds already have a default database — if so, skip this step.
We’ll create a customers table:
CREATE TABLE customers (
id INT PRIMARY KEY,
name VARCHAR(50),
city VARCHAR(50),
age INT
);
Explanation:
id → Whole number, unique for each customer (PRIMARY KEY).name → Up to 50 characters.city → Up to 50 characters.age → Whole number.INSERT INTO customers (id, name, city, age)
VALUES
(1, 'John Doe', 'New York', 30),
(2, 'Mary Smith', 'London', 25),
(3, 'Ravi Kumar', 'Mumbai', 28),
(4, 'Anita Sharma', 'Mumbai', 32);
SELECT * FROM customers;
* means all columns.
Let’s update a customer’s city:
UPDATE customers
SET city = 'Delhi'
WHERE id = 3;
Remove a customer:
DELETE FROM customers
WHERE id = 4;
Delete the entire table (use with caution!):
DROP TABLE customers;
SELECT = select), but uppercase improves readability.;).WHERE in UPDATE and DELETE unless you want to affect all rows.Try these in your SQL playground:
Create a products table with:
id (int, primary key)name (varchar, 50)price (decimal)stock (int)SELECT * FROM customers;
* means “all columns”.SELECT name, city FROM customers;
name and city columns.WHEREGet customers from Mumbai:
SELECT * FROM customers
WHERE city = 'Mumbai';
Get customers older than 27:
SELECT * FROM customers
WHERE age > 27;
| Operator | Meaning |
|---|---|
= | Equal to |
<> or != | Not equal to |
< | Less than |
> | Greater than |
<= | Less than or equal to |
>= | Greater than or equal to |
Example — customers not from London:
SELECT * FROM customers
WHERE city <> 'London';
LIKELIKE is used for pattern matching:
% = any number of characters_ = exactly one characterExample — names starting with ‘M’:
SELECT * FROM customers
WHERE name LIKE 'M%';
Example — names with ‘ar’ anywhere:
SELECT * FROM customers
WHERE name LIKE '%ar%';
ORDER BYSort customers by age (youngest first):
SELECT * FROM customers
ORDER BY age ASC;
Sort by name in reverse alphabetical order:
SELECT * FROM customers
ORDER BY name DESC;
Get the first 2 customers:
SELECT * FROM customers
LIMIT 2;
Get 2 customers starting from the 3rd row:
SELECT * FROM customers
LIMIT 2 OFFSET 2;
SELECT name, city, age
FROM customers
WHERE city = 'Mumbai'
ORDER BY age DESC;
name and age of customers older than 28.'it' anywhere.AND and ORGet customers from Mumbai and older than 30:
SELECT * FROM customers
WHERE city = 'Mumbai' AND age > 30;
Get customers from Mumbai or London:
SELECT * FROM customers
WHERE city = 'Mumbai' OR city = 'London';
Tip:
ANDhas higher priority thanOR, so use parentheses for clarity:
WHERE (city = 'Mumbai' OR city = 'London') AND age > 25;
IN / NOT INGet customers from Mumbai, London, or New York:
SELECT * FROM customers
WHERE city IN ('Mumbai', 'London', 'New York');
Exclude those cities:
SELECT * FROM customers
WHERE city NOT IN ('Mumbai', 'London', 'New York');
BETWEENFind customers aged between 25 and 30 (inclusive):
SELECT * FROM customers
WHERE age BETWEEN 25 AND 30;
NULL ValuesNULL means “no value” — it’s not the same as 0 or an empty string.
Find customers with no city:
SELECT * FROM customers
WHERE city IS NULL;
Find customers where age is known:
SELECT * FROM customers
WHERE age IS NOT NULL;
Example — customers from Mumbai or London and age is between 25 and 30:
SELECT * FROM customers
WHERE city IN ('Mumbai', 'London')
AND age BETWEEN 25 AND 30;
NULL).customers tableCREATE TABLE customers (
id INT PRIMARY KEY,
name VARCHAR(50),
city VARCHAR(50),
age INT
);
INSERT INTO customers (id, name, city, age) VALUES
(1, 'John Doe', 'New York', 30),
(2, 'Mary Smith', 'London', 25),
(3, 'Ravi Kumar', 'Mumbai', 28),
(4, 'Anita Sharma', 'Mumbai', 32),
(5, 'Raj Patel', NULL, NULL);
orders tableCREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT,
product VARCHAR(50),
price DECIMAL(10,2),
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
INSERT INTO orders (id, customer_id, product, price) VALUES
(1, 1, 'Laptop', 800.00),
(2, 1, 'Mouse', 20.00),
(3, 2, 'Keyboard', 50.00),
(4, 3, 'Laptop', 900.00),
(5, 3, 'Mouse', 25.00),
(6, 4, 'Monitor', 300.00);
Returns rows where there is a match in both tables.
SELECT customers.name, customers.city, orders.product, orders.price
FROM customers
INNER JOIN orders
ON customers.id = orders.customer_id;
Result:
| name | city | product | price |
|---|---|---|---|
| John Doe | New York | Laptop | 800.00 |
| John Doe | New York | Mouse | 20.00 |
| Mary Smith | London | Keyboard | 50.00 |
| Ravi Kumar | Mumbai | Laptop | 900.00 |
| Ravi Kumar | Mumbai | Mouse | 25.00 |
| Anita Sharma | Mumbai | Monitor | 300.00 |
Returns all rows from the left table (customers), even if no matching orders exist.
SELECT customers.name, orders.product, orders.price
FROM customers
LEFT JOIN orders
ON customers.id = orders.customer_id;
Note: Raj Patel will appear with NULL for product & price.
Returns all rows from the right table (orders), even if no matching customer exists. (Not supported in SQLite — works in MySQL/PostgreSQL.)
SELECT customers.name, orders.product, orders.price
FROM customers
RIGHT JOIN orders
ON customers.id = orders.customer_id;
Returns all rows from both tables, matched where possible. (PostgreSQL supports it; MySQL requires UNION trick.)
SELECT customers.name, orders.product, orders.price
FROM customers
FULL OUTER JOIN orders
ON customers.id = orders.customer_id;
A table joins with itself (e.g., employees & their managers). Let’s make an example:
CREATE TABLE employees (
id INT PRIMARY KEY,
name VARCHAR(50),
manager_id INT
);
INSERT INTO employees (id, name, manager_id) VALUES
(1, 'Alice', NULL),
(2, 'Bob', 1),
(3, 'Charlie', 1),
(4, 'David', 2);
SELECT e1.name AS employee, e2.name AS manager
FROM employees e1
LEFT JOIN employees e2
ON e1.manager_id = e2.id;
-- Create a products table
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(50)
);
INSERT INTO products (id, name) VALUES
(1, 'Laptop'),
(2, 'Mouse'),
(3, 'Keyboard'),
(4, 'Monitor');
-- Join customers → orders → products
SELECT customers.name, products.name AS product_name, orders.price
FROM customers
JOIN orders ON customers.id = orders.customer_id
JOIN products ON orders.product = products.name;
NULL if no orders exist.SUM with GROUP BY).customers tableCREATE TABLE customers (
id INT PRIMARY KEY,
name VARCHAR(50),
city VARCHAR(50),
age INT
);
INSERT INTO customers (id, name, city, age) VALUES
(1, 'John Doe', 'New York', 30),
(2, 'Mary Smith', 'London', 25),
(3, 'Ravi Kumar', 'Mumbai', 28),
(4, 'Anita Sharma', 'Mumbai', 32),
(5, 'Raj Patel', NULL, NULL);
orders tableCREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT,
product VARCHAR(50),
price DECIMAL(10,2),
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
INSERT INTO orders (id, customer_id, product, price) VALUES
(1, 1, 'Laptop', 800.00),
(2, 1, 'Mouse', 20.00),
(3, 2, 'Keyboard', 50.00),
(4, 3, 'Laptop', 900.00),
(5, 3, 'Mouse', 25.00),
(6, 4, 'Monitor', 300.00);
Count rows in a table:
SELECT COUNT(*) AS total_customers
FROM customers;
Count orders placed:
SELECT COUNT(*) AS total_orders
FROM orders;
Total sales from all orders:
SELECT SUM(price) AS total_sales
FROM orders;
Average age of customers:
SELECT AVG(age) AS avg_age
FROM customers;
Youngest and oldest customer:
SELECT MIN(age) AS youngest, MAX(age) AS oldest
FROM customers;
Total orders per customer:
SELECT customer_id, COUNT(*) AS order_count
FROM orders
GROUP BY customer_id;
Total sales per customer:
SELECT customer_id, SUM(price) AS total_spent
FROM orders
GROUP BY customer_id;
Sometimes we want all products bought by a customer in one row. We can use:
GROUP_CONCAT → MySQL/MariaDBSTRING_AGG → PostgreSQL/SQL ServerMySQL Example:
SELECT c.name AS customer_name,
GROUP_CONCAT(o.product ORDER BY o.product SEPARATOR ', ') AS products_bought
FROM customers c
JOIN orders o ON c.id = o.customer_id
GROUP BY c.name;
PostgreSQL Example:
SELECT c.name AS customer_name,
STRING_AGG(o.product, ', ' ORDER BY o.product) AS products_bought
FROM customers c
JOIN orders o ON c.id = o.customer_id
GROUP BY c.name;
Example: total products bought per customer per city:
SELECT c.city, c.name, COUNT(*) AS total_products
FROM orders o
JOIN customers c ON c.id = o.customer_id
GROUP BY c.city, c.name;
Filter grouped results — customers who spent more than $500:
SELECT customer_id, SUM(price) AS total_spent
FROM orders
GROUP BY customer_id
HAVING SUM(price) > 500;
Top customers by spending:
SELECT customer_id, SUM(price) AS total_spent
FROM orders
GROUP BY customer_id
ORDER BY total_spent DESC;
Subqueries and Common Table Expressions (CTEs) let you break complex problems into smaller steps. Instead of writing a huge, hard-to-read query, you can:
customers tableCREATE TABLE customers (
id INT PRIMARY KEY,
name VARCHAR(50),
city VARCHAR(50),
age INT
);
INSERT INTO customers (id, name, city, age) VALUES
(1, 'John Doe', 'New York', 30),
(2, 'Mary Smith', 'London', 25),
(3, 'Ravi Kumar', 'Mumbai', 28),
(4, 'Anita Sharma', 'Mumbai', 32),
(5, 'Raj Patel', NULL, NULL);
orders tableCREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT,
product VARCHAR(50),
price DECIMAL(10,2),
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
INSERT INTO orders (id, customer_id, product, price) VALUES
(1, 1, 'Laptop', 800.00),
(2, 1, 'Mouse', 20.00),
(3, 2, 'Keyboard', 50.00),
(4, 3, 'Laptop', 900.00),
(5, 3, 'Mouse', 25.00),
(6, 4, 'Monitor', 300.00);
A subquery is a query inside parentheses used as part of another query.
Find customers who spent more than $500:
SELECT name
FROM customers
WHERE id IN (
SELECT customer_id
FROM orders
GROUP BY customer_id
HAVING SUM(price) > 500
);
Show each customer with their total spending:
SELECT name,
(SELECT SUM(price)
FROM orders
WHERE orders.customer_id = customers.id) AS total_spent
FROM customers;
First, get total spending per customer, then filter:
SELECT *
FROM (
SELECT customer_id, SUM(price) AS total_spent
FROM orders
GROUP BY customer_id
) AS spending
WHERE total_spent > 500;
A Common Table Expression starts with WITH. It creates a temporary, named result that can be used in the main query.
WITH spending AS (
SELECT customer_id, SUM(price) AS total_spent
FROM orders
GROUP BY customer_id
)
SELECT customer_id, total_spent
FROM spending
WHERE total_spent > 500;
WITH spending AS (
SELECT customer_id, SUM(price) AS total_spent
FROM orders
GROUP BY customer_id
),
customer_info AS (
SELECT id, name, city
FROM customers
)
SELECT c.name, c.city, s.total_spent
FROM customer_info c
JOIN spending s ON c.id = s.customer_id;
Useful for hierarchical data like categories or org charts.
Example: an employee hierarchy:
CREATE TABLE employees (
id INT PRIMARY KEY,
name VARCHAR(50),
manager_id INT
);
INSERT INTO employees (id, name, manager_id) VALUES
(1, 'Alice', NULL),
(2, 'Bob', 1),
(3, 'Charlie', 1),
(4, 'David', 2);
WITH RECURSIVE hierarchy AS (
SELECT id, name, manager_id, 0 AS level
FROM employees
WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id, h.level + 1
FROM employees e
JOIN hierarchy h ON e.manager_id = h.id
)
SELECT * FROM hierarchy;
SELECT to show each customer’s average order price.This chapter covers:
customers tableCREATE TABLE customers (
id INT PRIMARY KEY,
name VARCHAR(50),
city VARCHAR(50),
age INT
);
INSERT INTO customers (id, name, city, age) VALUES
(1, 'John Doe', 'New York', 30),
(2, 'Mary Smith', 'London', 25),
(3, 'Ravi Kumar', 'Mumbai', 28);
orders tableCREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT,
product VARCHAR(50),
price DECIMAL(10,2),
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
INSERT INTO orders (id, customer_id, product, price) VALUES
(1, 1, 'Laptop', 800.00),
(2, 1, 'Mouse', 20.00),
(3, 2, 'Keyboard', 50.00);
Insert a single customer:
INSERT INTO customers (id, name, city, age)
VALUES (4, 'Anita Sharma', 'Mumbai', 32);
Insert multiple customers at once:
INSERT INTO customers (id, name, city, age) VALUES
(5, 'Raj Patel', 'Delhi', 29),
(6, 'Sara Khan', 'Bangalore', 27);
Change a customer’s city:
UPDATE customers
SET city = 'Delhi'
WHERE id = 3;
Change multiple fields:
UPDATE customers
SET city = 'Chennai', age = 33
WHERE name = 'Anita Sharma';
Increase all product prices by 10%:
UPDATE orders
SET price = price * 1.10;
⚠️ Always use WHERE unless you truly want to update every row.
Delete one customer:
DELETE FROM customers
WHERE id = 5;
Delete all orders for a customer:
DELETE FROM orders
WHERE customer_id = 1;
⚠️ Without WHERE, all rows will be deleted:
DELETE FROM customers; -- removes all customers!
Create an archive table:
CREATE TABLE orders_archive (
id INT,
customer_id INT,
product VARCHAR(50),
price DECIMAL(10,2)
);
Copy orders above $100 into the archive:
INSERT INTO orders_archive (id, customer_id, product, price)
SELECT id, customer_id, product, price
FROM orders
WHERE price > 100;
high_value_orders table and copy all orders over $500 into it.Constraints are rules that ensure data in your database is valid, consistent, and reliable. They prevent things like:
We’ll cover:
PRIMARY KEYFOREIGN KEYUNIQUENOT NULLCHECKDEFAULTcustomers table with constraintsCREATE TABLE customers (
id INT PRIMARY KEY, -- Must be unique, cannot be NULL
name VARCHAR(50) NOT NULL, -- Must have a value
email VARCHAR(100) UNIQUE, -- Must be unique
city VARCHAR(50) DEFAULT 'Unknown', -- Defaults if not provided
age INT CHECK (age >= 18) -- Must be at least 18
);
INSERT INTO customers (id, name, email, city, age) VALUES
(1, 'John Doe', 'john@example.com', 'New York', 30),
(2, 'Mary Smith', 'mary@example.com', 'London', 25);
orders table with FOREIGN KEYCREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT,
product VARCHAR(50) NOT NULL,
price DECIMAL(10,2) CHECK (price > 0),
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
INSERT INTO orders (id, customer_id, product, price) VALUES
(1, 1, 'Laptop', 800.00),
(2, 2, 'Mouse', 20.00);
id in both customers and orders tables.If you try to insert a duplicate:
INSERT INTO customers (id, name, email, age)
VALUES (1, 'Duplicate ID', 'dup@example.com', 30);
-- ERROR: Duplicate entry for PRIMARY KEY
email in customers table.INSERT INTO customers (id, name, email, age)
VALUES (3, 'Ravi Kumar', 'john@example.com', 28);
-- ERROR: Duplicate email not allowed
name in customers table.INSERT INTO customers (id, name, email, age)
VALUES (3, NULL, 'ravi@example.com', 28);
-- ERROR: name cannot be NULL
city defaults to ‘Unknown’.INSERT INTO customers (id, name, email, age)
VALUES (3, 'Ravi Kumar', 'ravi@example.com', 28);
SELECT * FROM customers WHERE id = 3;
-- city will show 'Unknown'
age >= 18 in customers table.INSERT INTO customers (id, name, email, city, age)
VALUES (4, 'Anita Sharma', 'anita@example.com', 'Mumbai', 15);
-- ERROR: Age must be at least 18
customer_id in orders must exist in customers.INSERT INTO orders (id, customer_id, product, price)
VALUES (3, 99, 'Keyboard', 50.00);
-- ERROR: customer_id 99 does not exist in customers
id in customers.customer_id that doesn’t exist.Indexes are special data structures that make queries faster — especially searches, sorting, and filtering. They work like an index in a book: instead of scanning every page, the database jumps directly to the location of the data.
customers tableCREATE TABLE customers (
id INT PRIMARY KEY,
name VARCHAR(50),
city VARCHAR(50),
age INT
);
-- Insert 10,000 sample rows for performance testing
INSERT INTO customers (id, name, city, age)
SELECT seq, CONCAT('Customer ', seq),
CASE WHEN seq % 3 = 0 THEN 'Mumbai'
WHEN seq % 3 = 1 THEN 'London'
ELSE 'New York' END,
20 + (seq % 30)
FROM generate_series(1, 10000) AS seq; -- PostgreSQL syntax
Note: If your SQL playground doesn’t support
generate_series, you can insert fewer rows manually — the indexing concept works the same.
If you search without an index:
SELECT * FROM customers
WHERE city = 'Mumbai';
The database scans every row — called a full table scan.
Create an index on city:
CREATE INDEX idx_customers_city
ON customers(city);
Now the database can jump directly to rows where city = 'Mumbai'.
If you often search by city and age together:
CREATE INDEX idx_customers_city_age
ON customers(city, age);
This is more efficient than two separate indexes in some cases.
A UNIQUE constraint is actually a special type of index:
CREATE UNIQUE INDEX idx_customers_name_city
ON customers(name, city);
This prevents duplicate name+city combinations.
DROP INDEX idx_customers_city;
(Some databases require DROP INDEX ON customers idx_name syntax.)
\d customers;
SHOW INDEXES FROM customers;
INSERT/UPDATE/DELETE)age and test queries before & after.city and name.EXPLAIN).Window functions let you perform calculations across a set of rows that are related to the current row, without collapsing them into a single result like GROUP BY does. They’re very useful for:
orders tableCREATE TABLE orders (
id INT PRIMARY KEY,
customer_name VARCHAR(50),
order_date DATE,
amount DECIMAL(10,2)
);
INSERT INTO orders (id, customer_name, order_date, amount) VALUES
(1, 'John Doe', '2024-01-05', 500.00),
(2, 'Mary Smith', '2024-01-06', 300.00),
(3, 'John Doe', '2024-01-10', 200.00),
(4, 'Mary Smith', '2024-01-15', 450.00),
(5, 'Ravi Kumar', '2024-01-16', 700.00),
(6, 'John Doe', '2024-01-20', 150.00),
(7, 'Ravi Kumar', '2024-01-22', 100.00);
Gives each row a unique number within a partition (group), ordered by some column.
SELECT customer_name, order_date, amount,
ROW_NUMBER() OVER (PARTITION BY customer_name ORDER BY order_date) AS order_number
FROM orders;
💡 This shows which order number each customer placed chronologically.
Ranks rows but gives the same rank for ties.
SELECT customer_name, amount,
RANK() OVER (ORDER BY amount DESC) AS rank_amount
FROM orders;
Like RANK, but without gaps in ranking numbers.
SELECT customer_name, amount,
DENSE_RANK() OVER (ORDER BY amount DESC) AS dense_rank_amount
FROM orders;
Cumulative sum of amounts per customer.
SELECT customer_name, order_date, amount,
SUM(amount) OVER (PARTITION BY customer_name ORDER BY order_date) AS running_total
FROM orders;
Average of the current and previous row (based on date).
SELECT customer_name, order_date, amount,
AVG(amount) OVER (PARTITION BY customer_name ORDER BY order_date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS moving_avg
FROM orders;
Shows where a row stands between 0 and 1 relative to others.
SELECT customer_name, amount,
PERCENT_RANK() OVER (ORDER BY amount DESC) AS pct_rank
FROM orders;
Splits rows into n buckets.
Example — split into 3 revenue groups:
SELECT customer_name, amount,
NTILE(3) OVER (ORDER BY amount DESC) AS revenue_group
FROM orders;
ROW_NUMBER for order sequence.amount for each customer using RANK.amount for all customers combined.NTILE.Views
SELECT from it just like a normal table.Stored Routines
CREATE TABLE customers (
id INT PRIMARY KEY,
name VARCHAR(50),
city VARCHAR(50)
);
CREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT,
product VARCHAR(50),
price DECIMAL(10,2),
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
INSERT INTO customers (id, name, city) VALUES
(1, 'John Doe', 'New York'),
(2, 'Mary Smith', 'London'),
(3, 'Ravi Kumar', 'Mumbai');
INSERT INTO orders (id, customer_id, product, price) VALUES
(1, 1, 'Laptop', 800.00),
(2, 1, 'Mouse', 20.00),
(3, 2, 'Keyboard', 50.00),
(4, 3, 'Laptop', 900.00),
(5, 3, 'Mouse', 25.00);
Example: total spending per customer.
CREATE VIEW customer_spending AS
SELECT c.id, c.name, c.city, SUM(o.price) AS total_spent
FROM customers c
JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name, c.city;
SELECT * FROM customer_spending;
This query is now simple because the complex join is inside the view.
CREATE OR REPLACE VIEW customer_spending AS
SELECT c.id, c.name, c.city, SUM(o.price) AS total_spent, COUNT(o.id) AS order_count
FROM customers c
JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name, c.city;
DROP VIEW customer_spending;
CREATE PROCEDURE with different calling syntax)Example: Get all orders for a given customer.
DELIMITER //
CREATE PROCEDURE GetCustomerOrders(IN cust_id INT)
BEGIN
SELECT * FROM orders
WHERE customer_id = cust_id;
END //
DELIMITER ;
Call the procedure:
CALL GetCustomerOrders(1);
Example: Get total spending for a given customer.
DELIMITER //
CREATE FUNCTION TotalSpent(cust_id INT)
RETURNS DECIMAL(10,2)
DETERMINISTIC
BEGIN
DECLARE total DECIMAL(10,2);
SELECT SUM(price) INTO total
FROM orders
WHERE customer_id = cust_id;
RETURN total;
END //
DELIMITER ;
Call the function:
SELECT TotalSpent(1) AS total_spent_by_customer;
A transaction is a group of SQL statements that run together as a single unit. They follow the ACID principles:
CREATE TABLE accounts (
id INT PRIMARY KEY,
name VARCHAR(50),
balance DECIMAL(10,2)
);
INSERT INTO accounts (id, name, balance) VALUES
(1, 'John Doe', 1000.00),
(2, 'Mary Smith', 1500.00);
We’ll transfer money from John to Mary.
START TRANSACTION;
UPDATE accounts
SET balance = balance - 200
WHERE id = 1;
UPDATE accounts
SET balance = balance + 200
WHERE id = 2;
COMMIT;
💡 If both updates succeed, we COMMIT to save changes.
If something goes wrong, cancel all changes:
START TRANSACTION;
UPDATE accounts
SET balance = balance - 500
WHERE id = 1;
-- Simulating an error
UPDATE accounts
SET balance = balance + 500
WHERE id = 999; -- no such account
ROLLBACK;
💡 After ROLLBACK, balances stay the same as before the transaction.
You can mark a point inside a transaction and roll back to it:
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
SAVEPOINT after_first_update;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- If this update is wrong:
ROLLBACK TO after_first_update;
COMMIT;
Locks prevent other transactions from changing the same data until you’re done.
Example: lock a row for update:
START TRANSACTION;
SELECT * FROM accounts
WHERE id = 1
FOR UPDATE;
-- Now update safely
UPDATE accounts SET balance = balance - 50 WHERE id = 1;
COMMIT;
💡 Other transactions trying to update id = 1 will wait until this one finishes.
Determines how transactions affect each other.
Common levels:
Example in MySQL:
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
-- Your queries here
COMMIT;
In this chapter, we’ll:
-- Customers
CREATE TABLE customers (
id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
city VARCHAR(50),
join_date DATE
);
-- Products
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
category VARCHAR(50),
price DECIMAL(10,2) CHECK (price > 0),
stock INT CHECK (stock >= 0)
);
-- Orders
CREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT,
order_date DATE,
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
-- Order Items (to handle multiple products per order)
CREATE TABLE order_items (
id INT PRIMARY KEY,
order_id INT,
product_id INT,
quantity INT CHECK (quantity > 0),
price DECIMAL(10,2) CHECK (price > 0),
FOREIGN KEY (order_id) REFERENCES orders(id),
FOREIGN KEY (product_id) REFERENCES products(id)
);
-- Customers
INSERT INTO customers (id, name, email, city, join_date) VALUES
(1, 'John Doe', 'john@example.com', 'New York', '2024-01-10'),
(2, 'Mary Smith', 'mary@example.com', 'London', '2024-02-15'),
(3, 'Ravi Kumar', 'ravi@example.com', 'Mumbai', '2024-03-05');
-- Products
INSERT INTO products (id, name, category, price, stock) VALUES
(1, 'Laptop', 'Electronics', 800.00, 10),
(2, 'Mouse', 'Electronics', 20.00, 100),
(3, 'Keyboard', 'Electronics', 50.00, 50),
(4, 'Monitor', 'Electronics', 300.00, 20),
(5, 'Desk Chair', 'Furniture', 150.00, 15);
-- Orders
INSERT INTO orders (id, customer_id, order_date) VALUES
(1, 1, '2024-03-10'),
(2, 1, '2024-03-15'),
(3, 2, '2024-03-20'),
(4, 3, '2024-03-25');
-- Order Items
INSERT INTO order_items (id, order_id, product_id, quantity, price) VALUES
(1, 1, 1, 1, 800.00),
(2, 1, 2, 2, 20.00),
(3, 2, 4, 1, 300.00),
(4, 3, 3, 1, 50.00),
(5, 4, 1, 1, 900.00),
(6, 4, 5, 2, 150.00);
SELECT p.name AS product_name,
SUM(oi.quantity) AS total_sold,
SUM(oi.quantity * oi.price) AS total_revenue
FROM order_items oi
JOIN products p ON oi.product_id = p.id
GROUP BY p.name
ORDER BY total_revenue DESC;
SELECT c.name AS customer_name,
SUM(oi.quantity * oi.price) AS total_spent
FROM customers c
JOIN orders o ON c.id = o.customer_id
JOIN order_items oi ON o.id = oi.order_id
GROUP BY c.name
ORDER BY total_spent DESC
LIMIT 5;
SELECT name, stock
FROM products
WHERE stock < 20
ORDER BY stock ASC;
SELECT DATE_FORMAT(o.order_date, '%Y-%m') AS month,
SUM(oi.quantity * oi.price) AS monthly_revenue
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
GROUP BY month
ORDER BY month;
(Use TO_CHAR(o.order_date, 'YYYY-MM') in PostgreSQL.)
SELECT c.name AS customer_name,
o.order_date,
p.name AS product_name,
oi.quantity,
oi.price
FROM customers c
JOIN orders o ON c.id = o.customer_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE c.id = 1
ORDER BY o.order_date;
SELECT p.category,
SUM(oi.quantity) AS total_sold
FROM order_items oi
JOIN products p ON oi.product_id = p.id
GROUP BY p.category
ORDER BY total_sold DESC
LIMIT 1;
You’ve now worked through SQL from the ground up — starting with simple queries and building up to joins, aggregations, subqueries, transactions, and real-world scenarios.
Along the way, you’ve learned how to combine, filter, and summarize data, as well as apply techniques to make queries faster and more reliable.
The best way to keep improving is to practice regularly. Use these techniques on real data, try new query combinations, and explore the features of your database system. With consistent use, SQL will become a natural and powerful tool in your problem-solving toolkit.
If you found this article interesting, found errors, or just want to discuss about them, please get in touch.