PostgreSQL SQL Fundamentals - Complete Notes
A ground-up guide to reading and changing relational data safely: SELECT, filtering, sorting, every join type, grouping, aggregates, subqueries, INSERT, UPDATE, DELETE and PostgreSQL's atomic ON CONFLICT upsert.
00. The relational mental model
SQL is a language for describing the result you want. You say which rows and columns should exist in the result; PostgreSQL decides how to find them.
A relational database stores data in tables. A table has named columns with declared types and zero or more rows. Each row is one fact of the kind represented by that table. A query does not normally change its input table. It produces another table-shaped result.
Think of a table as a carefully defined spreadsheet. Columns are the rules for what each field means, rows are the actual records, and a query is a fresh sheet assembled from the original data. Unlike an ordinary spreadsheet, the database enforces types and constraints for every row, even when thousands of users write concurrently.
| Term | Meaning | Example |
|---|---|---|
| Relation / table | A set of similarly shaped facts | customers |
| Tuple / row | One fact | Customer 42, Asha, Pune |
| Attribute / column | One named property with a type | email text |
| Primary key | A stable value that identifies one row | customer_id |
| Foreign key | A value that points to a row in another table | orders.customer_id |
| Result set | The rows produced by a statement | The output of SELECT |
The central shift is to stop thinking "loop over every customer." Think "the set of customers
whose city is Pune." One statement can affect one row, a million rows, or no rows. This is why a
missing WHERE on UPDATE or DELETE is so dangerous.
Every example in this guide uses this small shop schema:
CREATE TABLE customers (
customer_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
full_name text NOT NULL,
city text,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE products (
product_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
category text NOT NULL,
price numeric(12, 2) NOT NULL CHECK (price >= 0),
stock integer NOT NULL DEFAULT 0 CHECK (stock >= 0)
);
CREATE TABLE orders (
order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers(customer_id),
status text NOT NULL CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled')),
ordered_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders(order_id),
product_id bigint NOT NULL REFERENCES products(product_id),
quantity integer NOT NULL CHECK (quantity > 0),
unit_price numeric(12, 2) NOT NULL CHECK (unit_price >= 0),
PRIMARY KEY (order_id, product_id)
);
PostgreSQL keywords are case-insensitive, so select and SELECT mean
the same thing. This guide uppercases keywords for readability and uses lowercase
snake_case identifiers. Unquoted identifiers are folded to lowercase. Quoted names
preserve case and then require quotes forever, so avoid names such as
"CustomerName".
01. SELECT and the real execution order
SELECT reads data. Its written order is designed for humans, but its logical
processing order explains which names exist at each stage and why some queries fail.
SELECT column_or_expression AS output_name
FROM table_or_source
WHERE row_condition
GROUP BY grouping_expression
HAVING group_condition
ORDER BY sort_expression
LIMIT row_count
OFFSET rows_to_skip;
| Logical step | Clause | What happens |
|---|---|---|
| 1 | FROM / JOIN |
Build the input rows |
| 2 | WHERE |
Remove individual input rows |
| 3 | GROUP BY |
Form groups from the remaining rows |
| 4 | HAVING |
Remove whole groups |
| 5 | SELECT |
Compute output expressions and aliases |
| 6 | DISTINCT |
Remove duplicate output rows |
| 7 | ORDER BY |
Sort the result |
| 8 | LIMIT / OFFSET |
Return only the requested slice |
SELECT
product_id,
name,
price,
price * 1.18 AS price_with_tax
FROM products;
-- A literal and an expression can appear without coming from a table.
SELECT CURRENT_DATE AS today, 2 * 3 AS answer;
-- Qualify columns when more than one source could contain the same name.
SELECT p.product_id, p.name
FROM products AS p;
WHERE is logically processed before SELECT, so
WHERE price_with_tax > 1000 cannot see an alias created in the select list.
Repeat the expression, or move the calculation into a subquery. PostgreSQL does allow output
aliases in ORDER BY and, with restrictions, GROUP BY because those
stages occur after or alongside output processing.
SELECT * is useful while exploring. In application code, list the columns. It makes
the contract visible, avoids transferring unused data, prevents silently changing result shapes
when a column is added, and avoids duplicate column names in joined results.
02. WHERE, expressions and NULL
WHERE keeps rows whose condition evaluates to true. Both false and
unknown are discarded, which makes understanding NULL essential.
SELECT product_id, name, price
FROM products
WHERE category = 'book'
AND price >= 500
AND stock <> 0;
-- Standard comparisons: = <> < <= > >=
-- PostgreSQL also accepts !=, but <> is standard SQL.
SELECT *
FROM orders
WHERE status IN ('paid', 'shipped');
SELECT *
FROM products
WHERE price BETWEEN 500 AND 1000; -- inclusive at both ends
SELECT *
FROM customers
WHERE city = 'Pune' OR city = 'Mumbai';
a OR b AND c means a OR (b AND c). Use parentheses whenever conditions
mix AND and OR. Correct SQL that a reader misinterprets is still
unsafe SQL.
Text matching
SELECT * FROM customers WHERE full_name LIKE 'A%'; -- starts with A
SELECT * FROM customers WHERE full_name LIKE '%sha'; -- ends with sha
SELECT * FROM customers WHERE full_name LIKE '_avi'; -- one character, then avi
-- PostgreSQL extension: case-insensitive LIKE
SELECT * FROM customers WHERE full_name ILIKE '%asha%';
In a pattern, % matches any sequence of characters and _ matches exactly
one character. If user input should be treated literally, it must be escaped as pattern text as
well as passed as a parameter.
NULL and three-valued logic
NULL means missing or unknown, not zero, an empty string, or a special value. A
comparison with an unknown value is normally unknown, not true or false.
| Expression | Result | Reason |
|---|---|---|
NULL = NULL |
unknown | Two unknown values are not known to be equal |
NULL <> 5 |
unknown | The missing value cannot be compared |
NULL IS NULL |
true | IS NULL tests missingness itself |
NULL IS DISTINCT FROM NULL |
false | Null-safe equality semantics |
COALESCE(NULL, 'unknown') |
'unknown' |
First non-null argument wins |
SELECT * FROM customers WHERE city IS NULL;
SELECT * FROM customers WHERE city IS NOT NULL;
-- Null-safe comparison: true when the values differ, including one-null cases.
SELECT *
FROM customers
WHERE city IS DISTINCT FROM 'Pune';
-- Provide a display fallback without changing stored data.
SELECT full_name, COALESCE(city, 'City not provided') AS city
FROM customers;
Both evaluate to unknown for every row, so a WHERE clause keeps nothing. Use
IS NULL, IS NOT NULL, IS DISTINCT FROM, or
IS NOT DISTINCT FROM.
03. ORDER BY, LIMIT and pagination
A table has no inherent row order. Only ORDER BY promises an order in a query result.
SELECT product_id, name, price
FROM products
ORDER BY price DESC, product_id ASC;
-- PostgreSQL lets you choose where nulls appear.
SELECT customer_id, full_name, city
FROM customers
ORDER BY city ASC NULLS LAST, customer_id ASC;
SELECT order_id, ordered_at
FROM orders
ORDER BY ordered_at DESC, order_id DESC
LIMIT 20;
Sorting only by ordered_at is not fully deterministic because several orders can
share the same timestamp. Adding the primary key produces a total order. This matters for tests,
repeated requests, pagination and any query using LIMIT.
-- Page 6 with 20 rows per page. Simple, but the database must skip 100 rows.
SELECT order_id, ordered_at
FROM orders
ORDER BY ordered_at DESC, order_id DESC
LIMIT 20 OFFSET 100;
-- Keyset pagination: continue after the final row of the previous page.
SELECT order_id, ordered_at
FROM orders
WHERE (ordered_at, order_id) < (:last_ordered_at, :last_order_id)
ORDER BY ordered_at DESC, order_id DESC
LIMIT 20;
OFFSET is convenient for small result sets and page-number interfaces. Large offsets
become slower because the skipped rows still have to be found, and concurrent inserts can shift
rows between pages. Keyset pagination uses the last seen sort key, stays stable under inserts, and
scales better. It requires a deterministic order and is best for next/previous navigation.
04. Joins - combining related tables
A join combines rows from two sources according to a relationship. The join type decides what to do with rows that do not find a match.
SELECT
o.order_id,
o.status,
c.customer_id,
c.full_name
FROM orders AS o
INNER JOIN customers AS c
ON c.customer_id = o.customer_id;
-- INNER is optional. JOIN means INNER JOIN.
FROM orders AS o
JOIN customers AS c ON c.customer_id = o.customer_id
For each order, PostgreSQL looks for customers satisfying the ON condition. A
matching pair becomes one result row. If one customer has ten orders, that customer appears ten
times. Joins do not automatically remove duplicates because each matched pair is a real result
row.
SELECT
c.customer_id,
c.full_name,
o.order_id
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
ORDER BY c.customer_id, o.order_id;
Customers with no orders still appear once. Their o.* columns are filled with
NULL. This makes a left join useful for both optional relationships and finding
missing relationships.
SELECT c.customer_id, c.full_name
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;
A right-table filter in ON controls which rows match while preserving unmatched
left rows. The same filter in WHERE runs after the join and rejects the
null-extended rows, often turning a left join into an inner join by accident.
-- Keep every customer. Attach only paid orders.
SELECT c.customer_id, o.order_id
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
AND o.status = 'paid';
-- Keeps only customers that have a paid order. Customers without one disappear.
SELECT c.customer_id, o.order_id
FROM customers AS c
LEFT JOIN orders AS o
ON o.customer_id = c.customer_id
WHERE o.status = 'paid';
| Join | Rows preserved | Typical use |
|---|---|---|
INNER JOIN |
Only matching pairs | Orders with their customers |
LEFT JOIN |
Every left row | All customers, optionally with orders |
RIGHT JOIN |
Every right row | Same power as left join, with sides reversed |
FULL OUTER JOIN |
Every row from both sides | Reconcile two datasets and find mismatches |
CROSS JOIN |
Every possible pair | Generate combinations |
-- RIGHT JOIN preserves every customer. Usually clearer when rewritten as LEFT JOIN.
SELECT o.order_id, c.customer_id
FROM orders AS o
RIGHT JOIN customers AS c ON c.customer_id = o.customer_id;
-- Reconcile imported products with the real catalogue.
SELECT p.product_id, i.external_product_id
FROM products AS p
FULL OUTER JOIN imported_products AS i
ON i.sku = p.sku
WHERE p.product_id IS NULL OR i.external_product_id IS NULL;
-- Every size paired with every colour: 3 sizes x 4 colours = 12 rows.
SELECT s.size, c.colour
FROM sizes AS s
CROSS JOIN colours AS c;
ON versus USING
SELECT order_id, customer_id, full_name
FROM orders
JOIN customers USING (customer_id);
-- Equivalent matching condition:
SELECT o.order_id, o.customer_id, c.full_name
FROM orders AS o
JOIN customers AS c ON c.customer_id = o.customer_id;
USING (customer_id) is concise and produces one output
customer_id instead of one from each table. Use ON when names differ or
the condition is more complex. Never use NATURAL JOIN in durable code: it silently
joins on every same-named column, so a future schema change can change the query's meaning without
changing its text.
Self joins
-- employees.manager_id references employees.employee_id
SELECT
employee.full_name AS employee_name,
manager.full_name AS manager_name
FROM employees AS employee
LEFT JOIN employees AS manager
ON manager.employee_id = employee.manager_id;
05. GROUP BY, HAVING and aggregates
Aggregation turns many input rows into summary values. Without GROUP BY, all selected
rows form one group. With it, each distinct grouping key forms a separate group.
| Aggregate | Meaning | NULL behavior |
|---|---|---|
COUNT(*) |
Number of input rows | Counts rows even when columns contain null |
COUNT(column) |
Number of non-null values | Skips null |
SUM(column) |
Total | Skips null; null for no input rows |
AVG(column) |
Arithmetic mean | Skips null |
MIN / MAX |
Smallest / largest non-null value | Skips null |
SELECT
COUNT(*) AS product_count,
MIN(price) AS cheapest,
MAX(price) AS most_expensive,
AVG(price) AS average_price,
COALESCE(SUM(stock), 0) AS total_units
FROM products;
SELECT
category,
COUNT(*) AS product_count,
AVG(price) AS average_price
FROM products
WHERE stock > 0
GROUP BY category
HAVING COUNT(*) >= 3
ORDER BY average_price DESC;
WHERE stock > 0 removes products before grouping.
HAVING COUNT(*) >= 3
removes entire category groups after their counts exist. Put ordinary row conditions in
WHERE; reserve HAVING for conditions that depend on groups or
aggregates.
A grouped query may select grouping expressions and aggregate expressions. Selecting an unrelated column is ambiguous because the group can contain many different values for it. PostgreSQL rejects that query instead of choosing an arbitrary row.
SELECT
customer_id,
COUNT(*) AS all_orders,
COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders,
COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled_orders
FROM orders
GROUP BY customer_id;
SELECT
o.order_id,
SUM(oi.quantity * oi.unit_price) AS order_total
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
GROUP BY o.order_id;
-- COUNT DISTINCT protects against repeated customer ids after the join.
SELECT COUNT(DISTINCT o.customer_id) AS customers_who_ordered
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id;
The order of input to string_agg, array_agg and JSON aggregates is not
guaranteed unless the aggregate itself contains ORDER BY:
string_agg(name, ', ' ORDER BY name). An outer ORDER BY sorts result
rows, not values being collected inside one result row.
06. DISTINCT and set operations
SQL keeps duplicate rows by default. DISTINCT removes duplicates from the complete
selected row, while set operations combine the results of separate queries.
SELECT DISTINCT city
FROM customers;
SELECT DISTINCT city, active
FROM customers;
-- The second query removes duplicate (city, active) PAIRS, not duplicate cities alone.
SELECT COUNT(DISTINCT customer_id)
FROM orders;
If a join unexpectedly multiplies rows, first check the relationship and join condition. Adding
DISTINCT can hide a missing predicate, discard meaningful rows, and perform
unnecessary work. Use it when the required result is genuinely a set of unique values.
| Operation | Result | Duplicates |
|---|---|---|
UNION |
Rows in either query | Removed |
UNION ALL |
Rows in either query | Preserved |
INTERSECT |
Rows in both queries | Removed unless ALL |
EXCEPT |
Rows in the first query but not the second | Removed unless ALL |
SELECT email FROM customers
UNION
SELECT email FROM newsletter_subscribers;
-- Prefer ALL when duplicate removal is not required.
SELECT customer_id, 'order' AS source FROM orders
UNION ALL
SELECT customer_id, 'support' AS source FROM support_tickets;
Each branch must return the same number of columns with compatible types. Output column names come
from the first branch. A final ORDER BY sorts the combined result.
UNION ALL
is usually cheaper because PostgreSQL does not have to identify and remove duplicates.
07. Subqueries
A subquery is a query nested inside another statement. Its location determines whether it must return one value, one column, or an entire table.
Scalar subqueries
SELECT name, price
FROM products
WHERE price > (SELECT AVG(price) FROM products);
The inner query produces one average value, so it can be used where an ordinary value is expected.
Zero rows become NULL. More than one row causes an error because PostgreSQL cannot
choose which value was intended.
Subqueries in FROM
SELECT category_totals.category, category_totals.average_price
FROM (
SELECT category, AVG(price) AS average_price
FROM products
GROUP BY category
) AS category_totals
WHERE category_totals.average_price > 1000;
A subquery in FROM must have an alias. It is useful for creating a clear intermediate
result or making a computed alias available to an outer filter.
EXISTS and correlated subqueries
SELECT c.customer_id, c.full_name
FROM customers AS c
WHERE EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.customer_id
AND o.status = 'paid'
);
The inner query is correlated because it references the current outer
customer. EXISTS asks only whether at least one row exists. The selected value is
irrelevant, so SELECT 1 communicates intent. PostgreSQL may stop after the first
match.
SELECT c.customer_id, c.full_name
FROM customers AS c
WHERE NOT EXISTS (
SELECT 1
FROM orders AS o
WHERE o.customer_id = c.customer_id
);
IN, ANY and ALL
SELECT *
FROM customers
WHERE customer_id IN (
SELECT customer_id
FROM orders
WHERE status = 'paid'
);
-- More expensive than at least one product price.
SELECT * FROM products
WHERE price > ANY (SELECT price FROM products WHERE category = 'book');
-- More expensive than every product price in that category.
SELECT * FROM products
WHERE price > ALL (SELECT price FROM products WHERE category = 'book');
If the subquery returns even one NULL, and no equality match is found,
x NOT IN (...) becomes unknown rather than true. That can make the query return no
rows. Prefer a correctly correlated NOT EXISTS, or prove the subquery column cannot
be null.
08. INSERT - creating rows
INSERT adds rows. Always name the target columns so the statement remains readable
and safe when the table definition evolves.
INSERT INTO customers (email, full_name, city)
VALUES ('asha@example.com', 'Asha Rao', 'Pune');
INSERT INTO products (name, category, price, stock)
VALUES
('SQL Handbook', 'book', 799.00, 20),
('Mechanical Keyboard', 'electronics', 5999.00, 5),
('Notebook', 'stationery', 120.00, 100);
-- All columns use their declared default or NULL when allowed.
INSERT INTO audit_heartbeat DEFAULT VALUES;
Omitted columns receive their declared default. If no default exists, they receive
NULL, which fails if the column is NOT NULL. Explicitly writing
DEFAULT asks PostgreSQL to use the column default for that value.
INSERT INTO customers (email, full_name, city)
VALUES (:email, :full_name, :city)
RETURNING customer_id, email, created_at;
PostgreSQL can return the row after defaults, identity generation and triggers have run. This is safer than inserting and then querying by email or asking for a sequence's current value in a separate statement.
INSERT INTO archived_customers (customer_id, email, full_name, archived_at)
SELECT customer_id, email, full_name, CURRENT_TIMESTAMP
FROM customers
WHERE active = false
RETURNING customer_id;
09. UPDATE - changing existing rows
UPDATE changes every row that satisfies its WHERE condition. Without
WHERE, it changes every row in the table.
UPDATE products
SET
price = price * 1.05,
stock = stock + 10
WHERE category = 'book'
RETURNING product_id, name, price, stock;
Right-hand expressions read the row's old values. Assignments are conceptually simultaneous, so one assignment does not read another assignment's new value. The command count includes matched rows even when a new value equals the old value.
UPDATE products AS p
SET price = change.new_price
FROM price_changes AS change
WHERE change.product_id = p.product_id
RETURNING p.product_id, p.price;
If the join produces several source rows for one target row, PostgreSQL uses one of them, but which one is not predictable. Enforce uniqueness in the source or reduce it to one row per target before updating.
Before a broad update, run a SELECT with the same FROM and
WHERE. Check both the rows and the count. Then run the update in a transaction and
inspect RETURNING before committing.
10. DELETE - removing rows
DELETE removes selected rows and can return their old values. It is a data operation,
so constraints, triggers and transaction rollback still apply.
DELETE FROM orders
WHERE status = 'cancelled'
AND ordered_at < CURRENT_DATE - INTERVAL '1 year'
RETURNING order_id, customer_id, ordered_at;
DELETE FROM products AS p
USING discontinued_products AS d
WHERE d.product_id = p.product_id
RETURNING p.product_id, p.name;
Deleting a customer that still has orders normally fails because those orders would point to a row that no longer exists. The foreign key's configured action decides whether PostgreSQL rejects, cascades, nulls, or replaces the reference. Do not delete child rows blindly just to silence the error; understand the ownership rule first.
| Command | Purpose | Can filter rows? | Rollback normally? |
|---|---|---|---|
DELETE |
Remove selected rows | Yes | Yes |
TRUNCATE |
Remove all rows efficiently | No | Yes in PostgreSQL, subject to transaction rules |
DROP TABLE |
Remove the table object itself | Not applicable | Yes in a normal PostgreSQL transaction |
11. UPSERT with INSERT ON CONFLICT
An upsert means "insert this row, but if its unique identity already exists,
take an alternative action." PostgreSQL implements it atomically with
INSERT ... ON CONFLICT.
Two requests can both check that an email is absent, then both try to insert it. The unique
constraint prevents duplicate data, but one request fails. ON CONFLICT combines the
decision and write into one concurrency-safe statement.
INSERT INTO customers (email, full_name, city)
VALUES (:email, :full_name, :city)
ON CONFLICT (email) DO NOTHING
RETURNING customer_id;
The conflict target identifies the unique rule that arbitrates the conflict. If the email already
exists, PostgreSQL inserts nothing. In that case RETURNING returns no row because no
row was inserted or updated.
INSERT INTO customers AS existing (email, full_name, city)
VALUES (:email, :full_name, :city)
ON CONFLICT (email) DO UPDATE
SET
full_name = EXCLUDED.full_name,
city = EXCLUDED.city,
active = true
RETURNING existing.customer_id, existing.email, existing.full_name;
EXCLUDED represents the row that was proposed for insertion. The target table row is
the existing conflicting row. An explicit target alias such as existing makes that
distinction readable.
INSERT INTO inventory_snapshots AS current
(product_id, quantity, observed_at)
VALUES
(:product_id, :quantity, :observed_at)
ON CONFLICT (product_id) DO UPDATE
SET
quantity = EXCLUDED.quantity,
observed_at = EXCLUDED.observed_at
WHERE EXCLUDED.observed_at > current.observed_at
RETURNING product_id, quantity, observed_at;
The conflict target first finds and locks the conflicting row. The trailing WHERE
then decides whether that locked row should actually be updated. If the condition is false, the
statement returns no row for it.
ON CONFLICT is backed by a unique or exclusion constraint/index. It is not a
general "if any WHERE condition matches" feature. Decide the row's identity, enforce that
identity in the schema, and target it explicitly.
12. Safe writes and application boundaries
Correct syntax is only half the job. Production SQL must also be safe under bad input, broad predicates, partial failure and concurrent requests.
Use parameters, never string concatenation
-- Conceptual prepared statement. Placeholder syntax depends on the client library.
SELECT customer_id, email
FROM customers
WHERE email = :email;
-- Never build SQL like this in application code:
-- "SELECT ... WHERE email = '" + userInput + "'"
Parameters keep the SQL program separate from data, preventing SQL injection and handling quoting
and types correctly. Parameters represent values, not identifiers or keywords. If a user can
choose a sort column, map an allowed input such as "price" to a fixed SQL fragment
rather than injecting the raw text.
Preview destructive work in a transaction
BEGIN;
SELECT order_id, status
FROM orders
WHERE status = 'cancelled'
AND ordered_at < CURRENT_DATE - INTERVAL '1 year';
DELETE FROM orders
WHERE status = 'cancelled'
AND ordered_at < CURRENT_DATE - INTERVAL '1 year'
RETURNING order_id;
-- If the returned rows are correct:
COMMIT;
-- If anything is wrong, use this instead of COMMIT:
-- ROLLBACK;
- Verify the target set with
SELECT. - Use the exact same predicate in the write.
- Check the affected-row count or
RETURNINGoutput. - Set an expected maximum in application logic when a write should affect few rows.
- Keep the transaction short so locks are not held while a person goes for lunch.
Use half-open time ranges
SELECT *
FROM orders
WHERE ordered_at >= TIMESTAMPTZ '2026-07-01 00:00:00+05:30'
AND ordered_at < TIMESTAMPTZ '2026-08-01 00:00:00+05:30';
Avoid an end condition such as <= '2026-07-31 23:59:59'. Timestamps can contain
fractional seconds, so that boundary can miss valid rows. A half-open interval composes cleanly
with the next period and has no precision gap.
13. Gotchas that cause real bugs
1. No ORDER BY means no promised order.
A primary key, insertion order, current query plan or previous output does not create a result
ordering guarantee. Add ORDER BY, including a unique tie-breaker when pagination or
deterministic output matters.
2. A LEFT JOIN can accidentally become an INNER JOIN.
A right-side condition in WHERE rejects the null-extended unmatched rows. Put the
condition in ON when it should control matching while preserving every left row.
3. COUNT(*) and COUNT(column) answer different questions.
COUNT(*) counts rows. COUNT(city) counts rows whose city is not null.
The difference is intentional and often useful.
4. SUM of no rows is NULL, not zero.
Except for COUNT, ordinary aggregates normally return null when no input rows
exist. Use COALESCE(SUM(amount), 0) when the domain requires zero.
5. NOT IN can be poisoned by one NULL.
SQL cannot prove that a value differs from an unknown value, so the result becomes unknown.
Prefer NOT EXISTS for anti-joins.
6. BETWEEN is inclusive.
x BETWEEN 10 AND 20 includes both 10 and 20. For timestamps, half-open ranges are
usually safer than BETWEEN.
7. A join can multiply rows without being wrong.
Joining a customer to ten orders produces ten result rows. Understand the relationship's
cardinality before reaching for DISTINCT.
8. UPDATE and DELETE without WHERE affect the whole table.
PostgreSQL treats that as valid SQL. Preview, use a transaction, check the affected-row count, and make intentional whole-table operations visually obvious.
9. RETURNING reports rows changed by that statement.
It avoids a race-prone follow-up query and returns values after defaults and triggers. For
ON CONFLICT DO NOTHING, a conflicting row is not returned because nothing changed.
10. SQL injection is a code/data boundary failure.
Escaping by hand is fragile. Bind values as parameters and allow-list any dynamic identifier or direction that cannot be parameterized.
14. Practice scenarios with answers
Try to write each query before reading the answer. Producing SQL is what turns recognition into a usable skill.
1. Return the five most expensive in-stock products, deterministically.
SELECT product_id, name, price FROM products WHERE stock > 0 ORDER BY price DESC,
product_id ASC LIMIT 5;
The primary key breaks price ties.
2. Return every customer and the number of orders they have, including zero.
Use customers LEFT JOIN orders, group by the customer, and
COUNT(o.order_id). Do not use COUNT(*): the null-extended left-join
row would make a customer with no orders appear to have one.
SELECT
c.customer_id,
c.full_name,
COUNT(o.order_id) AS order_count
FROM customers AS c
LEFT JOIN orders AS o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.full_name
ORDER BY c.customer_id;
3. Find customers who have never placed an order.
Use NOT EXISTS correlated on customer_id. A left anti-join is also
valid, but NOT EXISTS states the requirement directly and avoids the
NOT IN
null trap.
4. Calculate each order total.
Join orders to order_items, group by order_id, and sum
quantity * unit_price. Use the stored item price rather than the product's current
price because an order records the price at purchase time.
5. Register a customer, updating their name and city if the email already exists.
Insert the proposed values, target the unique email constraint with
ON CONFLICT (email) DO UPDATE, read incoming values from EXCLUDED, and
use RETURNING to obtain the id in either case.
6. Why is WHERE COUNT(*) > 5 invalid?
WHERE runs before groups and their counts exist. Use
GROUP BY ... HAVING COUNT(*) > 5.
15. Interview Q&A
Q: What is the logical execution order of a SELECT query?
FROM/JOIN, WHERE, GROUP BY, HAVING,
SELECT, DISTINCT, ORDER BY, then
LIMIT/OFFSET. It explains why a select-list alias is unavailable in
WHERE but usable in ORDER BY.
Q: WHERE versus HAVING?
WHERE filters individual rows before grouping. HAVING filters complete
groups after aggregates exist. Use WHERE for ordinary row predicates whenever
possible.
Q: INNER JOIN versus LEFT JOIN?
Inner join returns only matching pairs. Left join returns every left row and fills right-side
columns with null when no match exists. Right-side filtering in WHERE can discard
those unmatched rows.
Q: UNION versus UNION ALL?
Both append compatible result sets. UNION removes duplicate rows;
UNION ALL preserves them and avoids duplicate-elimination work. Prefer
UNION ALL unless uniqueness is required.
Q: EXISTS versus IN?
EXISTS asks whether a correlated query returns any row. IN compares a
value with a one-column set. Both can express many positive membership checks; use the form that
communicates intent. For negative membership, NOT EXISTS avoids the
NOT IN null trap.
Q: What does NULL mean and why is NULL = NULL not true?
Null represents missing or unknown information. Two unknown values are not known to be equal, so
the comparison is unknown. Use IS NULL for missingness and
IS NOT DISTINCT FROM for null-safe equality.
Q: Why can a join create duplicates?
A join emits one result row for every matching pair. One-to-many and many-to-many relationships naturally repeat values from one side. That is row multiplication, not necessarily accidental duplication.
Q: What is an upsert and why not SELECT before INSERT?
An upsert inserts or takes an alternative action on a uniqueness conflict. A separate check and
insert races under concurrency. PostgreSQL's INSERT ... ON CONFLICT makes the
decision and write atomic.
Q: What is the purpose of RETURNING?
It returns expressions from rows changed by INSERT, UPDATE or
DELETE. It obtains generated ids, defaults, trigger-modified values and deleted-row
data without a second query.
Q: How do you make an UPDATE or DELETE safer?
Preview the same predicate with SELECT, run the write in a short transaction, use
RETURNING, verify the affected-row count, and commit only when the target set is
correct. Application code should reject unexpectedly broad writes.
16. One-screen cheat sheet
- Model: SQL describes a set of result rows; PostgreSQL chooses the execution plan.
-
Order:
FROM/JOIN-WHERE-GROUP BY-HAVING-SELECT-DISTINCT-ORDER BY-LIMIT. - NULL: use
IS NULL, never= NULL. -
Order: no
ORDER BYmeans no guarantee; add a unique tie-breaker. - Joins: inner = matches; left/right = preserve one side; full = preserve both; cross = every pair.
-
Outer join: right-side predicate in
ONpreserves unmatched left rows; inWHEREit usually removes them. -
Aggregation:
WHEREfilters rows;HAVINGfilters groups;COUNT(*)counts rows;COUNT(column)skips nulls. -
Duplicates:
DISTINCTapplies to the complete selected row;UNION ALLpreserves duplicates. -
Subqueries: scalar = one value;
EXISTS= at least one matching row; preferNOT EXISTSover nullableNOT IN. -
Writes: always name inserted columns; never forget that an omitted
WHEREaffects every row. -
PostgreSQL: use
RETURNINGfor changed rows and generated values. -
Upsert:
ON CONFLICT (unique_key) DO NOTHING/UPDATE;EXCLUDEDis the proposed row. - Safety: bind values as parameters; allow-list dynamic identifiers; preview writes; check affected rows; use short transactions.