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
SQL works with sets of rows

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:

Reference 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)
);
SQL keywords and names

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.

The basic shape
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 columns, expressions and aliases
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;
A SELECT alias usually does not exist in WHERE

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.

Avoid SELECT * in durable application queries

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.

Comparison and Boolean operators
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';
AND binds more tightly than OR

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

LIKE patterns
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
Correct NULL handling
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;
Never write = NULL or <> NULL

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.

Deterministic sorting
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;
Add a unique tie-breaker

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.

OFFSET pagination and keyset pagination
-- 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.

INNER JOIN: matched rows only
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.

LEFT JOIN: every left row, matched right rows when available
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.

Find customers with no orders: anti-join
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;
ON and WHERE are not interchangeable for an outer join

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.

The outer-join filter trap
-- 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, FULL and CROSS joins
-- 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

USING merges an identically named join column
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

One table can play two roles
-- 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
One summary row for the whole table
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;
One summary row per category
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 filters rows; HAVING filters groups

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.

Every selected value must have one value per group

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.

Conditional aggregation with FILTER
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;
Aggregate after a one-to-many join
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;
Ordering inside an aggregate is separate

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.

DISTINCT applies to the whole output row
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;
DISTINCT is not a repair tool for a wrong join

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
Combine compatible result sets
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

Exactly one row and one column
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

A query result used as a temporary table
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

Customers who have at least one paid order
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.

Customers with no orders: NOT EXISTS
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

Compare with a one-column result
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');
NOT IN has a NULL trap

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.

Single-row, multi-row and default inserts
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.

RETURNING avoids a second query
INSERT INTO customers (email, full_name, city)
VALUES (:email, :full_name, :city)
RETURNING customer_id, email, created_at;
Use RETURNING for database-generated values

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 from a query
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 with expressions and RETURNING
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 FROM joins another source
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;
Make UPDATE FROM one-to-one

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.

Preview the target set first

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 selected rows
DELETE FROM orders
WHERE status = 'cancelled'
  AND ordered_at < CURRENT_DATE - INTERVAL '1 year'
RETURNING order_id, customer_id, ordered_at;
DELETE USING joins another source
DELETE FROM products AS p
USING discontinued_products AS d
WHERE d.product_id = p.product_id
RETURNING p.product_id, p.name;
Foreign keys may reject a delete

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.

Why SELECT-then-INSERT is broken

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.

Ignore a duplicate
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 or update using EXCLUDED
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.

Update only when the incoming value is newer
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 DO UPDATE WHERE clause is not the conflict detector

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.

An upsert needs a real unique rule

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

Values belong in parameters
-- 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

A safe manual workflow
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 RETURNING output.
  • 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

Include the start, exclude the next start
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.

Answer 2
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 BY means 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 ON preserves unmatched left rows; in WHERE it usually removes them.
  • Aggregation: WHERE filters rows; HAVING filters groups; COUNT(*) counts rows; COUNT(column) skips nulls.
  • Duplicates: DISTINCT applies to the complete selected row; UNION ALL preserves duplicates.
  • Subqueries: scalar = one value; EXISTS = at least one matching row; prefer NOT EXISTS over nullable NOT IN.
  • Writes: always name inserted columns; never forget that an omitted WHERE affects every row.
  • PostgreSQL: use RETURNING for changed rows and generated values.
  • Upsert: ON CONFLICT (unique_key) DO NOTHING/UPDATE; EXCLUDED is the proposed row.
  • Safety: bind values as parameters; allow-list dynamic identifiers; preview writes; check affected rows; use short transactions.
Last reviewed · July 2026 · part of knowledge-base