Loading
Learn SELECT, INSERT, JOIN, and GROUP BY with practical examples using a real database schema.
SQL is the language of databases. Every web application stores data somewhere, and almost every time, that somewhere speaks SQL. Whether you use PostgreSQL, MySQL, SQLite, or a managed service like Supabase, the core language is the same. This guide teaches you the queries you will actually use.
SELECT retrieves data from a table. Imagine you have a users table:
| id | name | email | role | created_at | | --- | ----- | ----------------- | ---------- | ---------- | | 1 | Alice | alice@example.com | student | 2024-01-15 | | 2 | Bob | bob@example.com | instructor | 2024-02-20 | | 3 | Carol | carol@example.com | student | 2024-03-10 |
Key clauses and their order:
The order matters — you cannot put WHERE after ORDER BY. SQL has a strict clause sequence.
INSERT adds new rows. UPDATE modifies existing rows. DELETE removes rows.
A critical safety rule: never run UPDATE or DELETE without a WHERE clause. Without WHERE, the operation applies to every row in the table.
When in doubt, run a SELECT first with the same WHERE clause to verify which rows will be affected:
Real applications have multiple related tables. A lessons table might reference users through an instructor_id column:
users: | id | name | |----|------| | 1 | Alice | | 2 | Bob |
lessons: | id | title | instructor_id | |----|-------|---------------| | 10 | Intro to HTML | 2 | | 11 | CSS Basics | 2 | | 12 | JavaScript 101 | 1 |
JOIN combines rows from different tables based on a related column:
Result: | title | instructor_name | |-------|-----------------| | Intro to HTML | Bob | | CSS Basics | Bob | | JavaScript 101 | Alice |
Types of joins:
The LEFT JOIN result includes users who have no lessons (their title will be NULL):
| name | title | | ----- | -------------- | | Alice | JavaScript 101 | | Bob | Intro to HTML | | Bob | CSS Basics | | Carol | NULL |
Use LEFT JOIN when you want all rows from one table regardless of whether they have matching rows in the other.
GROUP BY combines rows that share a value and lets you run aggregate functions on each group.
Result: | name | lesson_count | |------|-------------| | Alice | 1 | | Bob | 2 | | Carol | 0 |
Common aggregate functions:
Filter groups with HAVING (like WHERE, but for aggregated results):
WHERE filters rows before grouping. HAVING filters groups after aggregation.
Here are queries you will write repeatedly in real applications.
Pagination:
Check if a record exists:
Upsert (insert or update):
Full-text search (PostgreSQL):
Subqueries:
Start with SELECT. Get comfortable reading data before modifying it. Run your WHERE clause as a SELECT before running it as a DELETE or UPDATE. And always remember: the database does exactly what you tell it to, even if what you told it was a mistake.
-- Get all columns for all users
SELECT * FROM users;
-- Get only specific columns
SELECT name, email FROM users;
-- Filter with WHERE
SELECT name, email FROM users WHERE role = 'student';
-- Multiple conditions
SELECT name FROM users
WHERE role = 'student' AND created_at > '2024-02-01';
-- Sort results
SELECT name, created_at FROM users ORDER BY created_at DESC;
-- Limit the number of results
SELECT name FROM users ORDER BY created_at DESC LIMIT 10;
-- Search with pattern matching
SELECT name FROM users WHERE email LIKE '%@example.com';SELECT columns
FROM table
WHERE conditions
ORDER BY column ASC|DESC
LIMIT count
OFFSET skip;-- Insert a single row
INSERT INTO users (name, email, role)
VALUES ('Dave', 'dave@example.com', 'student');
-- Insert multiple rows
INSERT INTO users (name, email, role)
VALUES
('Eve', 'eve@example.com', 'student'),
('Frank', 'frank@example.com', 'instructor');
-- Update specific rows (always use WHERE!)
UPDATE users
SET role = 'instructor'
WHERE email = 'alice@example.com';
-- Delete specific rows (always use WHERE!)
DELETE FROM users
WHERE id = 3;-- This deletes ALL users. Do not do this.
DELETE FROM users;
-- This is what you meant:
DELETE FROM users WHERE id = 3;-- Check first
SELECT * FROM users WHERE role = 'student';
-- Then delete if the results look correct
DELETE FROM users WHERE role = 'student';-- Get lessons with instructor names
SELECT lessons.title, users.name AS instructor_name
FROM lessons
JOIN users ON lessons.instructor_id = users.id;-- INNER JOIN: only rows that match in both tables
SELECT lessons.title, users.name
FROM lessons
JOIN users ON lessons.instructor_id = users.id;
-- LEFT JOIN: all rows from the left table, even without a match
SELECT users.name, lessons.title
FROM users
LEFT JOIN lessons ON users.id = lessons.instructor_id;-- Count lessons per instructor
SELECT users.name, COUNT(lessons.id) AS lesson_count
FROM users
LEFT JOIN lessons ON users.id = lessons.instructor_id
GROUP BY users.name;-- COUNT: how many rows
SELECT role, COUNT(*) AS user_count
FROM users
GROUP BY role;
-- SUM: total of a numeric column
SELECT instructor_id, SUM(duration_minutes) AS total_minutes
FROM lessons
GROUP BY instructor_id;
-- AVG: average value
SELECT instructor_id, AVG(duration_minutes) AS avg_duration
FROM lessons
GROUP BY instructor_id;
-- MIN and MAX
SELECT MIN(created_at) AS first_signup, MAX(created_at) AS latest_signup
FROM users;-- Instructors with more than 5 lessons
SELECT instructor_id, COUNT(*) AS lesson_count
FROM lessons
GROUP BY instructor_id
HAVING COUNT(*) > 5;-- Page 1 (items 1-20)
SELECT * FROM lessons ORDER BY created_at DESC LIMIT 20 OFFSET 0;
-- Page 2 (items 21-40)
SELECT * FROM lessons ORDER BY created_at DESC LIMIT 20 OFFSET 20;
-- Page N
-- OFFSET = (page_number - 1) * page_sizeSELECT EXISTS(
SELECT 1 FROM users WHERE email = 'alice@example.com'
);-- PostgreSQL syntax
INSERT INTO user_progress (user_id, lesson_id, percent_complete)
VALUES ('abc', 'lesson-1', 75)
ON CONFLICT (user_id, lesson_id)
DO UPDATE SET percent_complete = EXCLUDED.percent_complete;SELECT title, ts_rank(to_tsvector(content), query) AS rank
FROM lessons, to_tsquery('javascript & async') AS query
WHERE to_tsvector(content) @@ query
ORDER BY rank DESC;-- Find users who have completed at least one lesson
SELECT name FROM users
WHERE id IN (
SELECT DISTINCT user_id FROM progress WHERE percent_complete = 100
);