A query is a request you send to a database describing what data you want, not how to fetch it. That second half is the part the glossary definitions leave out, and it is the part that explains why two queries returning identical results can differ in speed by a thousandfold.
You describe the result. The database’s planner decides how to produce it — which indexes to use, what order to join tables in, whether to sort in memory or spill to disk. That separation is the central idea in relational databases, and once it clicks, both writing queries and fixing slow ones stop being guesswork.
Table of contents
- The declarative bit, and why it matters
- The four operations, and the one that is different
- What happens between sending and receiving
- Reading a query plan
- The slow-query patterns worth recognising
- Queries as an interface, not just a language
- How this fits the rest of the stack
- FAQ
The declarative bit, and why it matters
Compare two ways of getting the same answer. In application code you would write instructions:
open the customers file
for each row, if country is 'DE', remember the id
open the orders file
for each row, if customer_id is in that list, add the total
In SQL you write the result you want:
SELECT c.name, SUM(o.total) AS lifetime_value
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.country = 'DE'
GROUP BY c.name
ORDER BY lifetime_value DESC
LIMIT 10;
Nothing in that says which table to read first, whether to use an index on country, or which join algorithm to use. The planner decides, using statistics it keeps about how many rows exist and how values are distributed. On a table with a thousand German customers out of a million it will make one choice; with nine hundred thousand it will sensibly make a different one — for the same query text.
This is why performance advice of the form always write it this way is usually wrong. The right shape depends on your data, and the planner already knows more about your data than the advice does.
The four operations, and the one that is different
Almost all day-to-day SQL is four verbs.
SELECT id, email FROM users WHERE created_at > '2026-01-01';
INSERT INTO users (email, name) VALUES ('[email protected]', 'Ada');
UPDATE users SET name = 'Ada L' WHERE id = 42;
DELETE FROM users WHERE id = 42;
SELECT is read-only and can be retried freely. The other three change data and belong in transactions, so a failure halfway through leaves nothing half-applied.
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
That is the classic example for a reason: either both rows change or neither does. Without the transaction, a crash between the two statements loses a hundred units and nothing in the database objects.
The rule that saves the most grief: run every UPDATE and DELETE as a SELECT with the same WHERE clause first. Look at the row count. A DELETE with a mistyped condition does not ask for confirmation, and a missing WHERE clause deletes the table’s contents in one statement.
What happens between sending and receiving
Knowing the stages tells you where to look when something is slow.
- Parse. Text is checked for syntax and turned into a tree. Syntax errors surface here, before anything is touched.
- Analyse. Table and column names are resolved, types checked, permissions verified. Unknown column errors appear at this stage.
- Plan. The optimiser considers strategies and estimates their cost using table statistics. This is where the same query becomes fast or slow.
- Execute. The chosen plan runs — reading pages, applying filters, joining, sorting, aggregating.
- Return. Rows travel back over the connection.
Two of these are frequently the real problem and are invisible if you only look at the SQL. If statistics are stale, the planner estimates badly and picks a plan suited to a table shape that no longer exists — running ANALYZE fixes more slow queries than rewriting them does. And the return stage matters more than people expect: a query producing a hundred thousand rows so the application can count them is fast in the database and slow on the wire.
Reading a query plan
Every major database will show you the plan it intends to use. This is the single most useful debugging skill in SQL, and it takes an afternoon to learn.
-- PostgreSQL
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
-- MySQL
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
EXPLAIN shows the plan. EXPLAIN ANALYZE actually runs the query and shows estimated versus real numbers, which is far more useful. Be careful with it on UPDATE and DELETE — it really does execute them, so wrap it in a transaction you roll back.
The three things worth looking for first:
- Sequential scan on a large table with a selective
WHEREclause. Usually a missing index. On a small table it is correct and expected — reading a thousand rows directly beats an index lookup. - Estimated rows wildly different from actual rows. The planner is working from bad information. Run
ANALYZEon the table. - A sort or hash spilling to disk. Visible as external merge or disk in the output. Either the query pulls more than it needs, or the working-memory setting is too small.
Everything else is refinement. Those three cover the majority of genuinely slow queries you will meet.
The slow-query patterns worth recognising
- No index on a filtered column. The fix is an index, but only where it earns its place: indexes cost write performance and disk, so one per column is its own problem.
- A function wrapping an indexed column.
WHERE lower(email) = '[email protected]'cannot use a plain index onemail. Either index the expression or store the value normalised. SELECT *when you need two columns. Wasteful over the wire, and it prevents an index-only scan that would never touch the table at all.- N+1 queries. One query for a list, then one per row. A hundred rows becomes a hundred and one round trips. This is an ORM default in several frameworks and is the most common performance bug in web applications by a wide margin.
OFFSETdeep in a large result set.OFFSET 100000makes the database produce and discard a hundred thousand rows. Keyset pagination —WHERE id > :last_seen ORDER BY id LIMIT 50— stays constant-time.- An unbounded
INlist. A generatedINclause with ten thousand values plans badly. A join against a temporary table or a values list does better.
The N+1 one is worth dwelling on because it is invisible locally. With fifty rows of test data nobody notices. With fifty thousand in production it is the whole problem, and the SQL each individual query runs looks perfectly reasonable in isolation.
Queries as an interface, not just a language
One habit worth building early: treat the query as part of your application’s contract with the database, not as ad-hoc text assembled at runtime.
-- parameterised: the driver sends value and text separately
SELECT id, email FROM users WHERE email = $1;
Never build SQL by concatenating user input. Parameterised queries are not just the safe option against injection; they also let the database cache the plan, because the query text is stable across executions. Every mainstream driver supports them and there is no performance argument for the alternative.
Also worth doing: keep the queries your application depends on somewhere you can find them, whether that is a repository layer, a set of named statements, or migration-managed views. Queries scattered through controllers are queries nobody can audit when the table grows.
How this fits the rest of the stack
Most of what makes a query slow only becomes visible at production data volumes, which is why the useful loop is deploy, measure, read the plan, adjust. That loop is much shorter when the application logs and the database sit on the same platform and the slow request and the deploy that introduced it are readable together. RunxBuild runs managed MySQL and Postgres alongside the services that query them — databases on RunxBuild covers connecting, users, and backups. When you are sizing a database against expected traffic rather than guessing, the RunxBuild hosting calculator shows the database plan, the service, storage, and bandwidth as separate numbers so the arithmetic is visible.
Useful related references:
- postgresql psql list users: The Query That Actually Works on a Real Cluster
- Go Postgres: The Three Drivers, the Connection Pool, the Migration Story, and the Query Time Mistake
- List of Users in Postgres: The Safe Query That Works on Every Version, and the Role-Attribute Trap Behind It
- Databases on RunxBuild
FAQ
What is a query in a database?
A request sent to a database describing what data you want, without specifying how to retrieve it. The database’s query planner decides the retrieval strategy — which indexes to use, join order, sort method — based on statistics about your data. That separation is why identical results can come from wildly different execution costs.
What are the four main types of SQL query?
SELECT reads data, INSERT adds rows, UPDATE modifies existing rows, and DELETE removes them. SELECT is safe to retry; the other three change state and should run inside transactions so a partial failure leaves nothing half-applied.
How do I find out why a query is slow?
Run EXPLAIN ANALYZE on it. Look for a sequential scan on a large table with a selective filter, a large gap between estimated and actual row counts (run ANALYZE to refresh statistics), and any sort or hash that spills to disk. Those three cover most slow queries.
What is an N+1 query problem?
Running one query to fetch a list, then one additional query per row — a hundred rows becomes a hundred and one round trips. It is a common ORM default and is invisible with small test datasets. Fix it with an eager-loading join or a single query using an IN clause.
Why should I use parameterised queries?
They prevent SQL injection by sending the query text and the values separately, so user input can never be interpreted as SQL. They also let the database reuse a cached plan, since the query text stays constant across executions. Every mainstream driver supports them.