sql_claudenotes
sql_claudenotes
When you write code in Java, Python, or C++, you tell the computer how to do something
— step by step. SQL is the opposite. You tell the database what result you want, and the
database itself figures out how to compute it.
For example, when you write SELECT name FROM employees WHERE salary > 50000, you are not
telling the database to "open the file, loop through rows, check each one, append to a list."
You are stating a fact: "I want the names of employees whose salary exceeds 50000." The
database's query optimizer then decides:
Should I read the entire table sequentially (full table scan) or use an index?
If joining, should I use nested loops, a hash join, or a sort-merge join?
In what order should I apply filters?
Can I rewrite this query into an equivalent but faster form?
This is why two queries that look completely different can produce identical performance —
the optimizer rewrites them into the same internal plan. And it's why a "clever" looking query
can be slower than a "dumb" one if it confuses the optimizer.
Takeaway for you: Don't micromanage. Write clear, simple SQL. Trust the optimizer. Only
get clever when you've measured and there's a real problem.
This is the single biggest mental shift programmers from other backgrounds need to make.
SQL doesn't loop. When you write UPDATE employees SET salary = salary * 1.1 WHERE dept_id
= 1,the database doesn't iterate through rows like a for loop — it applies the operation to
the entire set of qualifying rows in one logical step.
If you find yourself thinking "for each row, do X" — you're probably writing slow or wrong
SQL. Instead, think: "given this set of rows, produce this transformed set." Joins are set
operations. GROUP BY is a set partition. Window functions are set computations. Embrace
sets.
The only time you legitimately fall back to row-by-row processing is in PL/SQL with cursors,
and even there it's discouraged for performance reasons.
1.3 The Logical Order of Query Execution — THE Most Important Concept
Why can't I use a column alias inside WHERE? Because WHERE (step 4) runs before
SELECT (step 7), so the alias doesn't exist yet. You can, however, use aliases in ORDER BY
because that runs after SELECT.
Why does WHERE COUNT(*) > 5 give an error? Aggregates are computed during GROUP
BY (step 5), but WHERE (step 4) runs before that. Use HAVING instead — it runs after
grouping.
Why are duplicate rows still there when I use DISTINCT with ORDER BY? DISTINCT
happens before ORDER BY, so the ordering doesn't affect duplicate detection.
Why does a query with a LEFT JOIN sometimes drop rows even though I expected
them to stay? Probably because your WHERE clause has a condition on the right-side
table that filters out rows where it's NULL — effectively converting the LEFT JOIN into an
INNER JOIN. Move that condition into the ON clause instead.
Memorize this order. Internalize it. It will resolve 70% of the confusion you ever feel about
SQL.
SQL doesn't use the regular TRUE/FALSE logic you're used to. It uses three-valued logic:
TRUE, FALSE, and UNKNOWN. Any expression involving NULL produces UNKNOWN, not
TRUE or FALSE.
NULL = NULL is not TRUE — it's UNKNOWN. Two unknowns aren't proven equal.
NULL != 5 is also UNKNOWN, not TRUE. You don't know the value, so you can't say it's
different.
WHERE x = NULL will never return rows. You must use WHERE x IS NULL.
WHERE x NOT IN (1, 2, NULL) returns ZERO rows, because x != NULL is UNKNOWN, which
fails the WHERE filter.
WHERE only keeps rows where the condition is TRUE. UNKNOWN and FALSE both get
filtered out. This is the source of countless bugs, including in production. Whenever you
write filters, ask yourself: "what happens if this column is NULL?"
A table is the fundamental container of data. Creating one means deciding what columns it
has, what types those columns are, and what rules (constraints) the database should enforce.
Constraints are your single most powerful tool for data integrity. They are rules enforced by
the database engine itself — meaning even if a buggy application tries to insert bad data,
the database will reject it. This protects you from yourself and from every future developer
who touches the system.
emp_id (PK) name (NN) email (UQ) salary (≥0, def 30000) dept_id (FK) hire_date
— — — — — —
PRIMARY KEY uniquely identifies each row. It implicitly enforces both UNIQUE and
NOT NULL. A table can have only one. Always pick a column whose value never
legitimately changes — an emp_id is good; an email is bad because people change
emails.
FOREIGN KEY ensures referential integrity. If dept_id = 5 exists in employees, then 5
must exist in departments. You can configure what happens when a referenced row is
deleted: ON DELETE CASCADE (also delete dependents), ON DELETE SET NULL (orphan them),
or ON DELETE RESTRICT (block the delete entirely — default).
UNIQUE prevents duplicates but unlike PRIMARY KEY, it permits one NULL value
(because NULL ≠ NULL, so they don't "collide").
NOT NULL is one of the most under-rated constraints. Every nullable column adds
branches to your query logic. Make columns NOT NULL whenever possible.
CHECK lets you enforce arbitrary boolean rules. CHECK (salary > 0) blocks negative
salaries. CHECK (status IN ('active', 'inactive', 'pending')) enforces enumerated
values.
DEFAULT supplies a value when INSERT omits the column. Useful for timestamps
(DEFAULT CURRENT_TIMESTAMP) and status flags.
Insight: Always declare constraints when creating the table. Adding them later requires the
database to scan the entire table to verify existing data complies — locking the table for the
duration on large datasets. Doing it at creation is free.
Schemas change. Maybe you forgot a column. Maybe a column needs to be wider. ALTER
TABLE lets you change structure without dropping data.
The new column is added with NULL for existing rows. If you add it with a DEFAULT, existing
rows get that default value, but on very large tables this can lock the table briefly while the
database fills in the new column.
Drop a column:
Rename a column:
ALTER TABLE employees ADD CONSTRAINT chk_sal CHECK (salary > 0);
This will fail if existing rows violate the constraint — a useful safety check.
These three feel similar but behave very differently. Interviewers love this question because
the difference reveals whether you understand transactions, logging, and DDL vs DML.
DELETE is a DML command. It removes specific rows based on the WHERE clause. Each row
deletion is logged in the transaction log, which means:
TRUNCATE is a DDL command. It removes ALL rows by deallocating the underlying data
pages — essentially "forget where the data lived." Because nothing is logged row-by-row,
it's incredibly fast.
DROP is a DDL command that removes the entire table — data, structure, indexes,
constraints, triggers, everything. The table simply ceases to exist.
Always list columns explicitly. If someone later adds a column or reorders them, your INSERT
continues to work. The "lazy" version INSERT INTO employees VALUES (...) depends on column
order — it breaks the day someone restructures the table.
Multiple rows at once:
This is dramatically faster than four separate INSERTs because it's one network round-trip
and one transaction.
The SELECT runs first, producing a result set; those rows then get inserted into the target.
This is how you copy or migrate data between tables.
Starting with:
UPDATE employees
SET salary = salary * 1.10
WHERE dept_id = 1;
UPDATE employees
SET salary = 80000, dept_id = 3
WHERE emp_id = 2;
The catastrophic mistake every developer makes once: Running UPDATE employees SET
salary = 50000; without a WHERE clause sets EVERY row's salary to 50000. Always write your
WHERE clause first. Many developers run a SELECT with the same WHERE first to confirm
exactly which rows will be affected, then convert it to UPDATE.
Execution insight: The database evaluates WHERE first to identify target rows, locks them,
then applies the SET expression. If your SET references the column you're updating (e.g.,
salary = salary * 1.1), the original value is used as the input.
The row is gone, but the operation is logged so you can ROLLBACK if you haven't committed
yet. Same warning as UPDATE: a DELETE without WHERE deletes every row in the table.
Real-world data sync scenarios constantly need: "If this record exists, update it; if not, insert
it." Writing this as separate SELECT-then-INSERT-or-UPDATE has race conditions and is
wordy. MERGE handles it atomically.
Target employees:
Source new_data:
Result:
The database walks through every row in new_data, checks if it exists in employees by the join
condition, and either updates or inserts. Atomic, fast, single-statement.
Each clause has a precise role. Remember the logical order from Chapter 1: FROM is
processed first, SELECT later. This means filtering happens before projection, and
aggregation happens before final selection.
WHERE is evaluated for every row independently. If the expression evaluates to TRUE, the
row passes through to the next stage. FALSE or UNKNOWN (from NULLs) — filtered out.
Given:
This will NOT return Vik (the row with NULL salary), because NULL != 50000 evaluates to
UNKNOWN, which fails the WHERE filter. To include him: WHERE salary != 50000 OR salary IS
NULL. This catches countless developers in production.
DISTINCT removes duplicate rows from the SELECT projection. Important: it works across the
entire row of selected columns, not individual columns.
Under the hood, DISTINCT typically requires a sort or hash operation to identify duplicates
— it isn't free. If you don't need uniqueness, skip it.
Sorts primarily by salary descending; when two rows have the same salary, secondary sort by
name ascending. You can sort by:
Column names
Column position numbers (ORDER BY 2 sorts by the second SELECT column — fragile,
avoid)
Expressions (ORDER BY salary * 12)
Aliases defined in SELECT (because ORDER BY runs after SELECT)
CASE expressions for custom orderings
NULL ordering: Databases differ in default behavior. Oracle puts NULLs last by default in
ASC. PostgreSQL puts them last. MySQL puts them first. Use NULLS FIRST or NULLS LAST
explicitly when it matters.
-- MySQL, PostgreSQL
SELECT * FROM employees ORDER BY salary DESC LIMIT 5;
-- SQL Server
SELECT TOP 5 * FROM employees ORDER BY salary DESC;
-- Oracle (older)
SELECT * FROM employees WHERE ROWNUM <= 5 ORDER BY salary DESC;
OFFSET 20 skips the first 20 rows. Note that ORDER BY is mandatory for pagination —
without it, "rows 21 to 30" is meaningless because rows have no inherent order.
Performance note: Large OFFSETs are slow. OFFSET 100000 makes the database materialize
100000 rows and throw them away. For deep pagination, use keyset pagination (WHERE id >
last_seen_id) instead.
CASE evaluates top-to-bottom and stops at the first matching WHEN. The ELSE is optional
but recommended — without it, unmatched rows return NULL.
You'll use CASE constantly: for pivoting (turning rows into columns), conditional aggregation
(SUM(CASE WHEN status='paid' THEN amount ELSE 0 END)), custom sort orders (ORDER BY CASE
status WHEN 'active' THEN 1 ELSE 2 END), and computed columns.
Aggregates collapse multiple rows into a single value. The fundamental ones:
The critical NULL behavior: Every aggregate except COUNT(*) silently ignores NULLs. This is
usually what you want, but sometimes it bites you. If salaries are [100, 200, NULL], then
AVG(salary) = 150 (sum 300 ÷ count 2), not 100 (sum 300 ÷ count 3). If you want NULLs
treated as zero, do AVG(COALESCE(salary, 0)).
COUNT(*)is special — it counts rows, not values. Use it when you want "how many rows total"
regardless of NULL.
GROUP BY divides rows into groups based on column values, and then aggregates compute
one value per group. Conceptually, you're saying "bucket these rows by X, then summarize
each bucket."
Starting data:
THE GOLDEN RULE that trips up beginners: Every column in SELECT must either appear in
GROUP BY or be wrapped in an aggregate function. Why? Because each group becomes one
row in the output, and the database can't decide which individual value to pick for a non-
grouped column. If you GROUP BY dept_id and try to SELECT name, the database asks "which
name? There are three in this group." Some databases (like older MySQL) silently picked an
arbitrary one and let your code run — leading to subtle bugs. Modern databases reject this
query.
The WHERE clause says "only consider employees hired in 2020 or later." The HAVING clause
says "among the resulting departments, only show those whose average exceeds 55000."
Two completely different operations even though both use comparison operators.
Why two clauses? Because the operation happens at different stages. WHERE can't use
aggregates because aggregates don't exist yet. HAVING can use them because grouping has
already occurred.
Imagine two tables sitting side-by-side. A JOIN matches rows from one with rows from the
other based on a condition (almost always equality on a key). The different join types differ
only in what they do with rows that don't have a match.
employees:
departments:
dept_id dept_name
1 Engineering
2 Marketing
3 Finance
6.2 INNER JOIN — Only Matches
Returns rows where the join condition is satisfied in BOTH tables. Unmatched rows from
either side are discarded.
name dept_name
Riya Engineering
Amit Marketing
Sara is excluded because her dept_id is NULL (and NULL doesn't match anything). Finance is
excluded because no employee belongs to it. INNER JOIN is the strictest form — only
mutual matches survive.
Returns ALL rows from the left table, paired with matching rows from the right table. Where
there's no match, the right side is filled with NULLs.
name dept_name
Riya Engineering
Amit Marketing
Sara NULL
Sara is kept (because she's on the left), but with NULL for department since no match exists.
Use LEFT JOIN when you want to keep "primary" data and optionally enrich it with secondary
data.
A classic interview pattern: "Find employees who have no department." You LEFT JOIN and
then filter the right side for NULL:
name dept_name
Riya Engineering
Amit Marketing
NULL Finance
In practice, RIGHT JOINs are rare — you can always rewrite them as LEFT JOINs by swapping
the tables, which most people find more readable.
Combines LEFT and RIGHT: all rows from both sides, matched where possible, NULLed where
not.
name dept_name
Riya Engineering
Amit Marketing
Sara NULL
NULL Finance
Useful for finding mismatches in both directions, like reconciling two systems.
Pairs every row from the left with every row from the right. No condition. If you have 3
employees and 3 departments, you get 9 rows.
Most often this is a bug (forgetting the ON clause), but legitimate uses include generating
combinations, creating test data, and "every customer × every product" reporting matrices.
employees:
To get "employee name → manager name" pairs, you join the table with itself using two
aliases:
employee manager
Riya NULL
Amit Riya
Sara Riya
Vik Amit
The aliases e and m are crucial — they're how the database distinguishes "the employee row"
from "the manager row," even though they're the same table.
For INNER JOIN, conditions in ON and WHERE behave identically. But for OUTER JOINs
(LEFT/RIGHT/FULL), they're different.
Conditions in ON are part of the join logic — they decide what counts as a match.
Unmatched rows still pass through with NULLs.
Conditions in WHERE are applied AFTER the join is done. They filter the combined result.
Compare:
Returns all employees; those in Marketing get the dept_name, others get NULL. The
condition filters the matches but not the employees.
-- LEFT JOIN with condition in WHERE
SELECT [Link], d.dept_name FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id
WHERE d.dept_name = 'Marketing';
Only returns Marketing employees. The WHERE applies after the join, dropping all rows
where dept_name isn't Marketing — effectively converting the LEFT JOIN into an INNER JOIN.
This single distinction has caused more bugs than nearly any other SQL concept.
Nested loop join — for each row in the outer table, scan the inner table for matches.
Simple but O(n*m). Efficient when one side is tiny or the inner side has an index on the
join column.
Hash join — build a hash table on the smaller side, then probe with rows from the
larger side. O(n+m). The default for large equality joins.
Sort-merge join — sort both sides by the join key, then walk through them in tandem.
Efficient when both sides are already sorted (e.g., by index).
You don't choose the algorithm directly — the optimizer picks based on table sizes, indexes,
and statistics. But understanding this helps you reason about performance: a join on indexed
columns is fast; a join on unindexed columns of two large tables can be brutal.
CHAPTER 7: SUBQUERIES
A subquery is a query inside another query. They're powerful because they let you compose
results — using the output of one query as input to another.
The inner query runs once, produces a single number (the company-wide average). The
outer query compares each row's salary to it. Because the inner result is a scalar, you can use
it wherever a single value is expected — including in SELECT or as a derived column.
When the subquery returns multiple values, use IN, ANY, ALL, or EXISTS:
SELECT name FROM employees
WHERE dept_id IN (SELECT dept_id FROM departments WHERE location = 'Mumbai');
ANYmeans "satisfies the condition with at least one value," and ALL means "satisfies it with
every value":
WHERE salary > ANY (SELECT salary FROM managers) -- > at least one manager
WHERE salary > ALL (SELECT salary FROM managers) -- > every manager
A correlated subquery references the outer query's columns. As a result, the inner query re-
runs for every row processed by the outer query. This is conceptually like a nested loop.
For each row e1, the inner subquery computes the average salary for THAT row's department,
then compares. This gives you employees earning above their own department's average —
a common interview ask.
Performance insight: Correlated subqueries can be slow because of the re-execution. The
optimizer often rewrites them as joins internally, but not always. If you can express the same
logic as a join with a GROUP BY (or use a window function), it's usually faster and more
readable.
INmaterializes the subquery into a list and checks membership. With a million-row
subquery, that's expensive.
EXISTS short-circuits — the moment it finds one match, it stops scanning. It returns a
boolean per outer row, never building a full list.
For large subqueries, prefer EXISTS. There's also the NULL trap: NOT IN with a list containing
NULL returns zero rows (because x != NULL is UNKNOWN). NOT EXISTS handles NULLs
correctly. Default to EXISTS/NOT EXISTS for safety.
Operator Behavior
UNION Combines results, removes duplicates
UNION ALL Combines results, keeps duplicates (faster)
INTERSECT Rows appearing in both queries
EXCEPT / MINUS Rows in the first query but not the second
The most important performance tip here: UNION removes duplicates, which requires
sorting or hashing the combined result. If you know your queries can't produce duplicates —
or you don't care — use UNION ALL. It's significantly faster because it skips the dedup step.
Combining them gets you far. For example, capitalizing the first letter:
Date handling varies by database — these are the Oracle/standard forms. MySQL and
PostgreSQL have their own equivalents.
SELECT SYSDATE; -- current date+time
SELECT CURRENT_DATE; -- today
SELECT EXTRACT(YEAR FROM hire_date) FROM employees;
SELECT ADD_MONTHS(SYSDATE, 6); -- 6 months from now
SELECT MONTHS_BETWEEN('2025-12-31', '2025-01-01'); -- ~12
SELECT TO_CHAR(SYSDATE, 'DD-MON-YYYY'); -- formatting to string
SELECT TO_DATE('15-08-2025', 'DD-MM-YYYY'); -- parsing from string
NULL handling functions are everyday tools because real data is always messy.
Aggregate functions with GROUP BY collapse rows: 10 rows in, 3 groups out. Window
functions compute aggregates across rows BUT keep every original row intact. You get the
per-row detail AND the per-group aggregate, side by side.
PARTITION BY — divides rows into independent groups (like GROUP BY conceptually, but
rows are not collapsed)
ORDER BY — orders rows within each partition (essential for ranking and running totals)
ROWS BETWEEN — optional, defines a sliding "frame" of rows over which the function
operates
The three rankers — ROW_NUMBER, RANK, DENSE_RANK — are critical for "find the top N" and "Nth
highest" problems.
ROW_NUMBER gives each row a unique number, with arbitrary tie-breaking. Even tied
salaries get different numbers.
RANKgives tied rows the same rank but then SKIPS subsequent ranks. Notice that after
two 1s, the next rank jumps to 3.
DENSE_RANK gives tied rows the same rank but does NOT skip. After two 1s, the next is 2.
Which one for "Nth highest salary"? Use DENSE_RANK — if two people tie for first, you still
want the next person to be "second highest," not "third highest." Use RANK if competition
logic applies ("if two people tie for first, no one is second"). Use ROW_NUMBER when you
specifically need unique ordinals, even for ties.
LAG gives you a previous row's value; LEAD gives you a future row's. Both within the
partition/order you define.
SELECT month, revenue,
LAG(revenue, 1) OVER (ORDER BY month) AS prev_month,
LEAD(revenue, 1) OVER (ORDER BY month) AS next_month
FROM monthly_sales;
Now you have the current month's revenue alongside last month's and next month's —
without joining the table to itself. This is the modern way to compute period-over-period
changes, churn, retention, and so on.
The OVER clause can include a frame specification — telling the database which subset of
rows to compute over for each output row.
Frame options:
For running totals, you almost always want ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
For moving averages over the last N rows, use ROWS BETWEEN (N-1) PRECEDING AND CURRENT ROW.
Whenever you find yourself wanting an aggregate but you want to keep each row's detail
too, that's a window function. Whenever you want to compare each row to a previous row,
that's LAG. Whenever you want rankings, that's ROW_NUMBER/RANK/DENSE_RANK. They've
replaced most "self-join + subquery" hackery.
1. Abstraction — hide complex joins or business logic behind a simple name. Users query
customer_revenue without knowing it's a 5-table join.
2. Security — grant access to the view, not the underlying tables. Users see only the
columns and rows the view exposes.
3. Stability — provide a stable interface even when the underlying tables change. You can
refactor schemas under the hood without breaking consumers.
The query against the view gets internally rewritten as a query against employees with both
conditions combined.
Materialized views are different: they physically store the result. Faster reads because you're
querying a real table, but the data can be stale. You refresh them periodically. Use them for
expensive aggregations that are queried far more often than the underlying data changes.
A transaction is a logical unit of work — one or more SQL statements that must either all
succeed or all fail together. The classic example: transferring money. If you debit account A
and crash before crediting account B, money has vanished. A transaction guarantees this
can't happen — either both updates succeed, or neither does.
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE acc_id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE acc_id = 2;
COMMIT;
COMMIT makes the changes permanent and visible to other sessions. Until you COMMIT,
your changes exist only in your session — others see the old data.
BEGIN;
UPDATE employees SET salary = 0 WHERE dept_id = 1;
-- "Wait, that was wrong!"
ROLLBACK;
-- salaries are back to original
Atomicity — all or nothing. A transaction either completes fully or is rolled back fully.
Partial commits don't happen.
Consistency — the database moves from one valid state to another. Constraints
(foreign keys, checks) are enforced. Application invariants are preserved.
Isolation — concurrent transactions don't interfere with each other. Each transaction
appears to run alone, even if many are happening simultaneously.
Durability — once a transaction commits, the changes survive crashes, power failures,
anything. They're written to durable storage (disk, with proper logging).
For long transactions, you can set checkpoints and roll back only to a checkpoint instead of
undoing everything.
BEGIN;
INSERT INTO orders VALUES (1, 'item1');
SAVEPOINT after_first;
INSERT INTO orders VALUES (2, 'item2');
ROLLBACK TO SAVEPOINT after_first; -- only the second insert is undone
INSERT INTO orders VALUES (3, 'item3');
COMMIT;
-- final result: rows for item1 and item3
When many transactions run simultaneously, weird things can happen if the database isn't
careful. The four anomalies:
Dirty read — reading data another transaction has modified but not yet committed. If
that transaction rolls back, you read data that never officially existed.
Non-repeatable read — reading the same row twice in your transaction and getting
different values because another transaction committed an update in between.
Phantom read — running the same query twice and getting different sets of rows
because another transaction inserted or deleted rows that match your filter.
Level Prevents
READ UNCOMMITTED Nothing — dirty reads possible
READ COMMITTED Dirty reads
REPEATABLE READ Dirty + non-repeatable reads
SERIALIZABLE All anomalies — strictest
Higher isolation gives you safer behavior but lower concurrency (more locking, more
blocking, more rollbacks). Most production systems use READ COMMITTED as the default —
it's a good balance.
lets the recipient further grant the privilege to others — useful for
WITH GRANT OPTION
delegating administration but dangerous because revoking it later can cascade.
Privileges are granular: SELECT, INSERT, UPDATE, DELETE on a specific table; EXECUTE on a
procedure; references on a column. Real production systems use ROLES — bundles of
privileges — assigned to users rather than granting privileges per-user.
Apply WHERE filters as early as possible to reduce the number of rows flowing into joins and
aggregations. Modern optimizers do this for you (it's called "predicate pushdown"), but
writing queries in a filter-first style helps in cases where the optimizer struggles.
As discussed in Chapter 7, EXISTS short-circuits at the first match. IN materializes the full list.
For large subqueries, EXISTS is typically faster.
When you wrap a column in a function, the database has to evaluate the function for every
row before it can compare. An index on hire_date doesn't help because the index stores raw
dates, not YEAR(hire_date) values. Always re-write to put the column bare on one side and
the computed value on the other.
The same applies to type conversions: comparing an INT column to a string forces an
implicit conversion on every row.
UNION removes duplicates, which requires an extra sort or hash. If you know the queries
can't produce duplicates — or you don't care — use UNION ALL. The difference can be
enormous on large result sets.
If you only need the top N, push LIMIT down as early as possible. Otherwise the database
might compute a million-row intermediate result and throw most of it away.
Every database has an EXPLAIN (or EXPLAIN PLAN) command that shows how a query will
be executed:
Look for: full table scans on large tables (often bad), large row count estimates, expensive
join algorithms on big tables. Optimize the most expensive step first. Use EXPLAIN
religiously — guessing about performance is a fool's errand.
Inserting 10,000 rows with 10,000 separate INSERT statements means 10,000 round-trips.
Batch into one statement, or use bulk-insert features. This single change can speed up data
loads by 100x or more.
LIMIT 10 OFFSET 1000000 makes the database fetch a million rows and discard them. For deep
pagination, use keyset pagination instead:
The index on id lets the database jump directly to the next page.
PART B — PL/SQL DEEP DIVE
Other databases have their own equivalents (T-SQL in SQL Server, PL/pgSQL in PostgreSQL),
but PL/SQL is the syllabus standard because Oracle dominated enterprise databases for
decades. The concepts transfer.
DECLARE
-- variable, constant, cursor, exception declarations (optional)
BEGIN
-- executable statements (required)
EXCEPTION
-- error handling (optional)
END;
/
The semicolon ends each statement. The forward slash on its own line tells the SQL*Plus or
SQL Developer tool to actually execute the block.
Anonymous block — not stored in the database. You type it, run it, it's gone. Useful for
one-off tasks or testing.
Named block — stored in the database for reuse: procedures, functions, packages, triggers.
These are the workhorses of production PL/SQL.
Blocks can be nested. Inner blocks have access to the outer block's variables (lexical
scoping). Exception handlers in inner blocks can re-raise unhandled exceptions to outer
blocks.
DECLARE
v_name VARCHAR2(50);
v_salary NUMBER(10, 2) := 50000;
c_tax_rate CONSTANT NUMBER := 0.18;
v_emp_sal [Link]%TYPE;
v_emp_row employees%ROWTYPE;
BEGIN
v_name := 'Riya';
DBMS_OUTPUT.PUT_LINE('Name: ' || v_name || ', Salary: ' || v_salary);
END;
/
Note the spelling: ELSIF, not ELSE IF (which is a common typo from Java/C). Conditions
evaluate top to bottom; the first TRUE branch executes and the rest are skipped.
PL/SQL has both, like SQL. The expression form returns a value; the statement form executes
a block:
-- Expression form
band := CASE
WHEN salary > 70000 THEN 'High'
WHEN salary > 40000 THEN 'Medium'
ELSE 'Low'
END;
-- Statement form
CASE
WHEN salary > 70000 THEN give_bonus(emp_id);
WHEN salary > 40000 THEN no_change(emp_id);
ELSE adjust_minimum(emp_id);
END CASE;
18.3 Loops
-- Reverse FOR
FOR i IN REVERSE 1..10 LOOP
DBMS_OUTPUT.PUT_LINE(i);
END LOOP;
The FOR loop's counter variable is implicitly declared and scoped to the loop. After the loop
ends, the variable doesn't exist. The range 1..10 is inclusive on both ends.
EXIT WHEN condition is cleaner than IF condition THEN EXIT; END IF; and idiomatic in PL/SQL.
Every SQL statement in PL/SQL automatically uses an implicit cursor. You don't declare it, but
you can query its attributes:
BEGIN
UPDATE employees SET salary = salary * 1.1 WHERE dept_id = 1;
DBMS_OUTPUT.PUT_LINE(SQL%ROWCOUNT || ' rows updated');
END;
For more control — especially when iterating through results — you use explicit cursors. The
lifecycle has four steps: declare, open, fetch, close.
DECLARE
CURSOR c_emp IS
SELECT emp_id, name FROM employees WHERE dept_id = 1;
v_id employees.emp_id%TYPE;
v_name [Link]%TYPE;
BEGIN
OPEN c_emp; -- executes the query
LOOP
FETCH c_emp INTO v_id, v_name; -- gets the next row
EXIT WHEN c_emp%NOTFOUND; -- exit when no more rows
DBMS_OUTPUT.PUT_LINE(v_id || ' - ' || v_name);
END LOOP;
CLOSE c_emp; -- frees resources
END;
The four-step pattern is verbose. PL/SQL gives you a much cleaner alternative — the cursor
FOR loop — which auto-opens, auto-fetches, and auto-closes:
BEGIN
FOR rec IN (SELECT emp_id, name FROM employees WHERE dept_id = 1) LOOP
DBMS_OUTPUT.PUT_LINE(rec.emp_id || ' - ' || [Link]);
END LOOP;
END;
The variable rec is implicitly declared as a record matching the SELECT's columns. The loop
opens the cursor, fetches each row into rec, and closes when done. Use this 90% of the time
— it's safer (no resource leaks) and shorter.
When you want the same cursor query with different filter values:
DECLARE
CURSOR c_emp(p_dept INT) IS
SELECT name FROM employees WHERE dept_id = p_dept;
BEGIN
FOR rec IN c_emp(2) LOOP
DBMS_OUTPUT.PUT_LINE([Link]);
END LOOP;
END;
The cursor declaration takes parameters; you pass values when opening.
CHAPTER 20: EXCEPTION HANDLING
In SQL, an error stops the statement. In PL/SQL, you can catch errors and respond — log
them, retry, or gracefully recover. This is essential for reliable code.
DECLARE
v_name [Link]%TYPE;
BEGIN
SELECT name INTO v_name FROM employees WHERE emp_id = 999;
EXCEPTION
WHEN NO_DATA_FOUND THEN
DBMS_OUTPUT.PUT_LINE('No employee with that ID');
WHEN TOO_MANY_ROWS THEN
DBMS_OUTPUT.PUT_LINE('More than one match');
WHEN OTHERS THEN
DBMS_OUTPUT.PUT_LINE('Unexpected error: ' || SQLERRM);
END;
When an exception fires inside BEGIN, control jumps immediately to EXCEPTION. The
handlers are checked top to bottom; the first match runs. After it runs, the block ends (or, if
nested, control returns to the outer block).
WHEN OTHERS is the catch-all — it handles anything not handled by a specific clause. Always
put it last. SQLERRM returns the error message; SQLCODE returns the numeric error code. Logging
these is good practice.
Declare it like a variable but with the type EXCEPTION. RAISE triggers it. The handler in
EXCEPTION processes it.
20.4 RAISE_APPLICATION_ERROR
If you want to throw a meaningful error back to the calling application (not just within your
PL/SQL), use this:
Error codes between -20000 and -20999 are reserved for application use. The calling code
sees this as a database error with your custom message.
Call it:
DECLARE
v_new_sal [Link]%TYPE;
BEGIN
give_raise(101, 10, v_new_sal);
DBMS_OUTPUT.PUT_LINE('New salary: ' || v_new_sal);
END;
Parameter modes:
IN (default) — input only. The procedure can read it but not change the caller's variable.
OUT — output only. The procedure writes to it; the caller reads the result.
IN OUT — both ways. The procedure can read the input and write a new value back.
-- Use in SQL
SELECT name, get_annual_salary(emp_id) AS annual FROM employees;
A function MUST return a value via a RETURN statement. A procedure doesn't return; it
produces effects through OUT parameters or DML.
Functions can be called inside SELECT statements; procedures cannot.
Best practice: functions should be "pure" — given the same input, return the same
output, with no side effects. Procedures are where side effects belong.
Use it:
BEGIN
emp_pkg.hire('Tara', 60000);
DBMS_OUTPUT.PUT_LINE(emp_pkg.get_salary(101));
END;
This trigger runs after any UPDATE that changes the salary column, logging the change to an
audit table. It fires once per row affected.
Trigger anatomy:
BEFORE / AFTER / INSTEAD OF — when does it fire? BEFORE lets you validate or
modify the change before it happens. AFTER fires after the change is committed to
memory (but before transaction commit). INSTEAD OF is for views and replaces the
underlying DML.
INSERT / UPDATE / DELETE — what event triggers it? You can list multiple events.
FOR EACH ROW — fires per row affected (row-level). Without this, it fires once per
statement regardless of row count (statement-level).
WHEN (condition) — optional filter to skip the trigger when the condition is false.
:OLD and :NEW — pseudo-records exposing the row's values. :OLD is the existing value
(available in UPDATE and DELETE); :NEW is the incoming value (available in INSERT and
UPDATE).
Use cases:
Cautions:
Triggers fire invisibly. Stacking many of them turns the database into a hidden state
machine that's a nightmare to debug.
Triggers run inside the same transaction as the triggering statement. A trigger failure
rolls back the original statement.
Be wary of "mutating table" errors — a row-level trigger can't query the table it's
defined on (because that table is mid-modification).
DECLARE
TYPE name_list IS TABLE OF VARCHAR2(50) INDEX BY PLS_INTEGER;
names name_list;
BEGIN
names(1) := 'Riya';
names(2) := 'Amit';
DBMS_OUTPUT.PUT_LINE(names(1));
END;
Instead of fetching one row at a time, fetch the entire result set into a collection:
DECLARE
TYPE emp_tab IS TABLE OF employees%ROWTYPE;
emps emp_tab;
BEGIN
SELECT * BULK COLLECT INTO emps FROM employees;
FOR i IN 1..[Link] LOOP
DBMS_OUTPUT.PUT_LINE(emps(i).name);
END LOOP;
END;
This is dramatically faster than row-by-row cursor processing because it minimizes context
switches between PL/SQL and SQL engines.
Same idea for INSERT/UPDATE/DELETE — apply DML to all elements of a collection in one
shot:
DECLARE
TYPE id_tab IS TABLE OF NUMBER;
ids id_tab := id_tab(101, 102, 103);
BEGIN
FORALL i IN 1..[Link]
UPDATE employees SET salary = salary * 1.1 WHERE emp_id = ids(i);
END;
One round-trip to the SQL engine instead of three. On collections of thousands of items, this
can be a 10-100x speedup over a regular FOR loop with individual UPDATEs.
The first solution is elegant — find the max excluding the actual max. The DENSE_RANK
version generalizes to Nth.
DENSE_RANK over DISTINCT salary handles ties correctly: tied salaries share a rank, no gaps.
Group by the candidate-duplicate column, then filter groups with more than one row.
The inner query identifies the "canonical" row per duplicate group (the one with the smallest
id). The outer query deletes everything else.
Partition by department, rank within each, keep only rank 1. If two people tie for top in a
department, both are returned.
Classic self-join. The same table is aliased twice — once as the employee, once as the
manager.
SELECT d.dept_name
FROM departments d
LEFT JOIN employees e ON d.dept_id = e.dept_id
WHERE e.emp_id IS NULL;
LEFT JOIN keeps all departments. Departments with no employees show NULL on the right
side; filter on that.
SELECT [Link]
FROM customers c
LEFT JOIN orders o ON c.cust_id = o.cust_id
WHERE o.order_id IS NULL;
Default frame for SUM with ORDER BY is "from start of partition through current row" —
perfect for running totals.
LAG gives the previous month's revenue inline; from there, compute the percentage change.
SELECT student_id,
MAX(CASE WHEN subject = 'Math' THEN marks END) AS Math,
MAX(CASE WHEN subject = 'Science' THEN marks END) AS Science,
MAX(CASE WHEN subject = 'English' THEN marks END) AS English
FROM marks
GROUP BY student_id;
The CASE expressions emit a value only for matching subjects; MAX collapses the rows per
student into one. Standard pivot pattern.
Median = middle value(s). For odd count, the middle row. For even count, average of the
two middle rows. The FLOOR/CEIL formula picks either one row or two depending on parity.
The clever trick: If login dates are consecutive, then login_date - row_number() is constant —
the gap to the row number doesn't change. Group by this gap, and each group is a
consecutive streak. Filter groups of length ≥ 3.
SELECT customer_id
FROM orders
GROUP BY customer_id
HAVING COUNT(DISTINCT product_id) = (SELECT COUNT(*) FROM products);
A customer has bought all products if their distinct product count equals the total product
count.
NOT EXISTS is the safest anti-join idiom (handles NULLs correctly, unlike NOT IN).
SELECT id + 1 AS missing_id_start
FROM orders o
WHERE NOT EXISTS (SELECT 1 FROM orders WHERE id = [Link] + 1)
AND id < (SELECT MAX(id) FROM orders);
For each row, check if id+1 exists. If not, that's where a gap starts.
Classic LeetCode-style problem: swap student names in seats (1,2), (3,4), etc. If the last seat
has odd id, leave it alone.
SELECT
CASE
WHEN id % 2 = 1 AND id = (SELECT MAX(id) FROM seats) THEN id
WHEN id % 2 = 1 THEN id + 1
ELSE id - 1
END AS id,
student
FROM seats
ORDER BY id;
Compute a new id per row using CASE, then sort by it. The names move with the new ids.
Tuple IN — match the (dept, salary) pair against (dept, max_salary) pairs.
Problem 21 — Departments Where Average Salary > Company Average
The HAVING clause compares the group's aggregate against a global scalar subquery.
How many distinct products had been bought up to and including each date?
SELECT order_date,
COUNT(DISTINCT product_id) OVER (
ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS distinct_products_so_far
FROM orders;
Note: not all databases support COUNT DISTINCT inside a window. For those, use a
subquery-based approach with EXISTS or correlated GROUP BY.
For each salary, count how many distinct salaries exceed it. The Nth highest is where this
count = N-1. Slow but useful when window functions aren't available.
Range condition on hire_date — keeps any index usable, unlike WHERE DATEDIFF(...) <= 30.
Problem 25 — Find the Manager With the Most Direct Reports
SELECT * FROM (
SELECT o.*,
ROW_NUMBER() OVER (PARTITION BY cust_id ORDER BY order_date DESC) AS rn
FROM orders o
) WHERE rn = 1;
For each customer's orders, rank by date descending, keep rank 1. Standard "latest per
group" pattern.
SELECT DISTINCT
LEAST(f1.user_id, f1.friend_id) AS u1,
GREATEST(f1.user_id, f1.friend_id) AS u2
FROM friendships f1
JOIN friendships f2
ON f1.user_id = f2.friend_id
AND f1.friend_id = f2.user_id;
Self-join the friendships table to find reciprocal entries. LEAST/GREATEST normalize the pair
so (a,b) and (b,a) don't appear as separate rows.
LAG with offsets 1 and 2 lets you compare three consecutive orders in a single row.
Note that you can use aggregates inside the OVER clause when combined with GROUP BY.
Each group's aggregate is ranked.
Recursive CTE — the anchor selects the CEO (no manager); the recursive part adds direct
reports of already-found rows, incrementing level. Continues until no new rows. This is THE
way to handle hierarchies in SQL.
For PL/SQL, focus on: block structure, cursors (especially the FOR loop form), exception
handling, the procedure/function distinction, packages, and triggers with :OLD/:NEW. Bulk
operations and dynamic SQL are good to know but lower priority.
Practice on a real database. SQL clicks only when you run queries and see results. Use a free
PostgreSQL, MySQL, or Oracle XE locally, or use online sandboxes like sqlfiddle, db-fiddle, or
LeetCode's SQL section.