Blog · Jul 10, 2026
Getting Started with SQL
SELECT, JOIN, GROUP BY, and a real blog schema you build in SQLite with sample data — verify every query against rows you can count by hand.
SQL is how you ask structured questions of tables. Whether you use Postgres, SQLite, or a warehouse, the core verbs stay the same. This post has you build a tiny blog schema, load real sample rows, and run queries whose output you can verify by counting the INSERTs yourself — no trust required.
Core verbs
SELECT id, title
FROM posts
WHERE published = TRUE
ORDER BY created_at DESC
LIMIT 10;
Aggregations:
SELECT author_id, COUNT(*) AS n_posts
FROM posts
GROUP BY author_id
HAVING COUNT(*) >= 3
ORDER BY n_posts DESC;
Joins — always explicit, never a bare comma:
SELECT u.name, p.title
FROM users u
JOIN posts p ON p.author_id = u.id
WHERE u.active = TRUE;
Mutations (use carefully in production):
INSERT INTO posts (author_id, title) VALUES (1, 'Hello');
UPDATE posts SET published = TRUE WHERE id = 42;
DELETE FROM comments WHERE id = 7;
Mental model
- Tables ≈ spreadsheets with types and constraints.
- Rows ≈ records; columns ≈ fields.
JOINstitches tables together on matching keys.GROUP BYcollapses rows into buckets;HAVINGfilters buckets,WHEREfilters rows before grouping.
Mini project: a blog schema with data you can verify
Use SQLite for zero setup — one binary, one file, no server.
sqlite3 blog.db
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE posts (
id INTEGER PRIMARY KEY,
author_id INTEGER NOT NULL REFERENCES users(id),
title TEXT NOT NULL
);
CREATE TABLE comments (
id INTEGER PRIMARY KEY,
post_id INTEGER NOT NULL REFERENCES posts(id),
user_id INTEGER NOT NULL REFERENCES users(id),
body TEXT NOT NULL
);
INSERT INTO users (name, email) VALUES
('Asha', 'asha@example.com'),
('Ravi', 'ravi@example.com'),
('Meera', 'meera@example.com');
INSERT INTO posts (author_id, title) VALUES
(1, 'Hello SQL'),
(1, 'Joins Explained'),
(2, 'Why Indexes Matter');
INSERT INTO comments (post_id, user_id, body) VALUES
(1, 2, 'Great intro!'),
(1, 3, 'Finally makes sense'),
(2, 3, 'More examples please');
Now run three queries and verify each against the INSERTs above before trusting the output:
-- 1. All comments on post 1, with commenter names
SELECT c.body, u.name AS commenter
FROM comments c
JOIN users u ON u.id = c.user_id
WHERE c.post_id = 1;
-- expect exactly 2 rows: Ravi's and Meera's comments on 'Hello SQL'
-- 2. Top commenters by comment count
SELECT u.name, COUNT(*) AS n_comments
FROM comments c
JOIN users u ON u.id = c.user_id
GROUP BY u.name
ORDER BY n_comments DESC;
-- expect Meera first with 2 (she commented on posts 1 and 2), Ravi with 1
-- 3. Posts with zero comments
SELECT p.title
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
WHERE c.id IS NULL;
-- expect exactly 'Why Indexes Matter' (post 3 has no comments above)
If your output doesn’t match the comments in each query, re-count the INSERT statements — that mismatch is the whole point of the exercise: SQL results are always derivable by hand from small data, so “it looks right” is never good enough.
Common footguns
WHERE x = NULLnever matches anything —NULLisn’t equal to anything, including itself. UseWHERE x IS NULL/IS NOT NULL.- SQLite is lenient about
GROUP BY— it lets youSELECTa non-aggregated, non-grouped column without erroring (many other databases reject this outright). The value you get back is essentially arbitrary; only select columns that are either grouped or aggregated. - Missing
ONclause → cartesian join —FROM a, bwithout a join condition returns every combination of rows from both tables, which silently explodes result size on real data. LEFT JOIN+WHEREon the right table’s column (notIS NULL) — turns your outer join back into an inner join by accident, becauseWHERE comments.body = 'x'filters out theNULLrows theLEFT JOINproduced.SELECT *in application code — breaks silently when someone adds or reorders a column; name the columns you actually use.- No index on a foreign key — joins on
author_idorpost_iddo a full table scan once your tables grow past a few thousand rows;CREATE INDEX idx_posts_author ON posts(author_id);fixes it.
You know you’re done when…
sqlite3 blog.db ".tables"listsusers,posts, andcomments- All three exercise queries return exactly the row counts predicted in the comments above
- You can explain why
WHERE comments.id IS NULLafter aLEFT JOINfinds posts with no comments — and whyWHERE comments.id = NULLwould return nothing at all - Every join in your queries uses explicit
JOIN ... ON, never a bare comma - You can name one column in this schema that would benefit from an index, and why
Next
APIs often wrap SQL — see Getting Started with FastAPI. RAG systems sometimes store metadata in SQL and vectors elsewhere — see LLM Mastery art. 40.