Module 3 · Databases

3.2 SQL: the four queries every builder must be able to read

Lesson 3.2 · 10 min read

SQL has a reputation problem. It sounds like something you'd need a semester to learn, and people who use it daily talk about it in a way that suggests deep arcane knowledge. The reality is almost comically different. SQL was designed, deliberately, to read like English sentences, and the four operations that make up the overwhelming majority of all database work can be genuinely understood in an afternoon.

Here's the honest framing. You do not need to be able to write complex SQL from memory. AI is extremely good at writing SQL, better than most humans, and it will write it for you. What you need is to read a query and know exactly what it does, to spot when it's doing something wrong or dangerous, and to direct it precisely. That's a far smaller skill than "learning SQL," and it's the one that actually matters. A query you can read is a query you can trust or correct. A query you can't read is a black box operating on your product's memory, which is a bad thing to have.

So let's read some SQL. By the end of this post you'll understand the four core operations, several things that surround them, and one query pattern that will save you from a genuinely catastrophic mistake.


What SQL is, and the one thing that makes it strange

SQL stands for Structured Query Language, and it's how you talk to a relational database, the table-based kind from the last post. Every question you ask of your data and every change you make to it is expressed as a SQL statement.

The one genuinely unusual thing about SQL, and the thing worth understanding before any syntax, is that it's declarative. In most code you've read, you tell the computer how to do something, step by step. In SQL, you describe what you want, and the database figures out how to get it. You don't tell it to scan through rows, check each one, and collect the matches. You say "give me the users whose city is Berlin," and the database, which knows far more about how it stores things than you do, works out the fastest way to find them.

That's why SQL reads like a request rather than a procedure. You're describing a result, not a method. Keep that in mind and the language stops feeling odd.

Everything below assumes a users table, with columns id, name, email, city, and created_at, exactly the kind of table you sketched in the last post's exercise.


Reading: SELECT

SELECT retrieves data. It asks a question and gets rows back. It changes nothing. This is the operation you'll read most, by a wide margin, because most of what a product does is show people things.

The simplest form, which you already met in the backend post:

SELECT * FROM users;

Read it literally: select everything from users. The * means "all columns." This returns every row of the users table, with every column. Simple, and quietly dangerous on a large table, since asking for a million rows is a real thing to do accidentally.

Usually you want specific columns:

SELECT name, email FROM users;

Select the name and email, from users. Now you get two columns instead of everything. And notice this connects directly to the security post: returning only the columns you need, rather than *, is exactly how you avoid leaking fields you never meant to send. The habit starts here, in the query.

Now the part that makes SELECT actually useful, WHERE, which filters:

SELECT name, email FROM users WHERE city = 'Berlin';

Select the name and email, from users, where the city is Berlin. It reads as English because it was designed to. WHERE is the condition that decides which rows come back. Without it, you get everything. With it, you get exactly the rows that match.

Conditions can combine, using AND and OR in the way you'd expect:

SELECT name FROM users WHERE city = 'Berlin' AND created_at > '2024-01-01';

Users in Berlin who signed up after the start of 2024. You can read that without help now, and that's the point.

Two more pieces you'll see on nearly every real SELECT. ORDER BY sorts the results, and LIMIT caps how many come back:

SELECT name, email FROM users ORDER BY created_at DESC LIMIT 10;

Select name and email from users, ordered by when they were created, descending (DESC, newest first), limited to 10. That's "the ten most recent signups," a query that exists in some form in nearly every product ever built.

Take a moment to appreciate that you can now read a real, useful, production-shaped query and say exactly what it returns. That's most of what reading SQL is.


Creating: INSERT

INSERT adds a new row. It's what runs when someone signs up, posts something, places an order.

INSERT INTO users (name, email, city)
VALUES ('Sarah', 'sarah@example.com', 'Berlin');

Insert into users, into these columns, these values. The first list names the columns you're filling, the second provides the matching values, in order. A new row appears in the table with those values, and the database assigns it an id automatically.

That's genuinely all there is to it. When your backend controller ran db.insert("orders", newOrder) back in the reading-code post, this is the SQL it produced underneath. The abstraction lifted; here's what was actually happening.


Changing: UPDATE, and the mistake that will hurt

UPDATE changes existing rows. And here we arrive at the most important paragraph in this post, so read it twice.

UPDATE users SET city = 'Munich' WHERE id = 42;

Update users, set the city to Munich, where the id is 42. One row changes, the one with id 42. Clean, precise, exactly what you wanted.

Now look at what happens if you forget the WHERE:

UPDATE users SET city = 'Munich';

Update users, set the city to Munich. Every user in your entire database now lives in Munich. Every single row, changed, instantly, with no confirmation and no undo. This is not a hypothetical horror story, it's one of the most famous and most repeated mistakes in the entire history of software, and it happens because the query without WHERE is perfectly valid SQL. The database will not stop you. It will do exactly what you said, to everything.

The lesson generalizes into a habit worth carrying forever: in any UPDATE or DELETE, the WHERE clause is not optional in practice, even though it is in syntax. It is the difference between changing one row and changing all of them. When you read a query an AI wrote that modifies data, the first thing your eye should go to, before anything else, is whether there's a WHERE and whether it's specific enough. That's a two-second check that prevents a catastrophe, and it's exactly the kind of judgment that makes reading SQL worth learning.


Removing: DELETE

DELETE removes rows, and everything from the last section applies with even more force, because deleted data doesn't come back.

DELETE FROM users WHERE id = 42;

Delete from users where the id is 42. One user, gone. And the version that will ruin your week:

DELETE FROM users;

Every user. Gone. No WHERE, no mercy.

A useful thing to know is that many mature products rarely truly delete anything. Instead they use a soft delete: a column like deleted_at that gets a timestamp when something is "deleted," and every query then filters out rows where that column is filled in. The data still exists, invisible to users, recoverable when someone inevitably asks. When you see a query filtering on WHERE deleted_at IS NULL, that's what's happening: only show me the things that haven't been deleted. It's a small pattern with real wisdom in it, and it's the kind of choice a thoughtful builder makes deliberately.

Interactive: sql query builder

A hands-on visual for this idea is in the works. The text around it carries everything you need for now.


Reading across tables: JOIN

Everything so far touched one table. But the whole point of the last post was that real data models have separate tables that reference each other by id. So how do you get a post and the name of the person who wrote it, when the post only holds an author_id?

You JOIN them, and this is the operation that intimidates people most and deserves it least.

SELECT posts.title, users.name
FROM posts
JOIN users ON posts.author_id = users.id;

Read it slowly and it disassembles cleanly. Select the post's title and the user's name. From posts. Join with users, matching where the post's author_id equals the user's id. That last line is the whole idea: it tells the database how the two tables connect. Follow the id from the post to the user, and now you can pull columns from both, side by side, in one result.

That's a JOIN. It's the id-based relationship from the last post, made real. It's why storing author_id instead of author_name was the right call: because a join lets you retrieve the author's name, picture, bio, anything, at any time, always current, by following the id. The structure permitted the feature, and the JOIN is how you cash it in.

Real applications join constantly, sometimes several tables in one query, and it can look dense. But every join is the same simple thing: two tables, connected by matching one's id column against the other's reference to it. When you see ON something.x_id = something_else.id, you're looking at a relationship being followed. That's the whole trick, and now you can read it.


One more thing worth knowing: aggregation

Products constantly need to count and summarize things. How many users, what's the total revenue, how many orders per customer. SQL does this with aggregate functions, and a small amount of knowledge here goes a long way.

SELECT COUNT(*) FROM users WHERE city = 'Berlin';

Count the rows in users where the city is Berlin. One number comes back. Similar functions exist for SUM, AVG, MAX, MIN, and they do what their names suggest.

The genuinely useful version adds GROUP BY, which computes the aggregate separately for each group:

SELECT city, COUNT(*)
FROM users
GROUP BY city;

Select the city and the count, from users, grouped by city. This returns one row per city, each with its user count. Berlin: 4,200. Munich: 3,100. And so on. Almost every dashboard, analytics view, and summary screen you've ever designed sits on a query shaped like this. When someone says "we need a chart of signups by month," they're describing a GROUP BY, and you now know that.


Why reading SQL is enough, and why it's necessary

Let's be clear about the bar we've set. You can now read a SELECT with filtering, sorting, and limits. You can read an INSERT, an UPDATE, and a DELETE, and you know precisely why the WHERE clause on the last two deserves your full attention. You can read a JOIN and see the relationship it's following. You can read a COUNT and a GROUP BY. That covers a genuinely large share of the SQL that exists in real applications.

You cannot yet write a complicated query from a blank screen, and that is completely fine, because that's the part AI does well. What AI cannot do is know that this UPDATE should have been scoped to one user, or that this SELECT is returning columns that shouldn't leave the server, or that this query will scan your entire orders table every time someone loads the homepage. Those are judgments about your product, made by someone who can read what was written. That someone is you.

There's one more thing worth naming, because it's where careless SQL becomes a security problem rather than just a correctness one. When user input gets pasted directly into a query, someone can craft input that changes the query's meaning, an attack called SQL injection. It's the classic database vulnerability. The fix is well-established, you never build a query by gluing user input into it; you pass the input separately as a parameter, so the database treats it strictly as a value and never as instructions. Modern tools do this by default, which is why this is less common than it once was. But now you know what it is, and if you ever see a query being assembled by sticking a user's input into the middle of it, you'll recognize the shape of a real problem. That recognition is exactly what reading SQL buys you.


Do this before the next post

Ask an AI to give you the SQL for a few things against a table you invent, and read every query it hands back before accepting it.

Try: "give me a SQL query to find the 5 most recent orders over $100 from customers in Germany, showing the customer's name." You'll get something with a SELECT, a WHERE with two conditions, a JOIN to the customers table, an ORDER BY, and a LIMIT. Read each clause and say aloud what it does. Every piece will be something from this post.

Then ask for "a query to mark an order as cancelled," and check it. Does it have a WHERE clause? Is that WHERE specific enough that it can only ever affect one order? If it isn't, you've just caught a real bug of the most dangerous kind, before it ever ran against real data.

That habit, reading the query before it runs, especially any query that changes data, is one of the highest-value two-second checks in all of building. Everything in this post exists so you can perform it with confidence.