The five sublanguages

SQL is often taught as one language, but every statement belongs to one of five groups, split by what it operates on: structure, rows returned, row data, permissions, or the unit of work itself.

DDL

structure

Data Definition Language. Creates and reshapes schema objects — tables, indexes, views, schemas. Most engines auto-commit DDL immediately, so it can't be rolled back like ordinary row changes.

CREATEALTERDROPTRUNCATERENAME

DQL

retrieval

Data Query Language. Technically one statement — SELECT — but its clauses (filtering, grouping, ordering, limiting) do most of the heavy lifting in day-to-day SQL.

SELECT

DML

row data

Data Manipulation Language. Adds, changes, or removes row-level data without touching the shape of the table. Wrapped in a transaction, these are what COMMIT and ROLLBACK act on.

INSERTUPDATEDELETEMERGE

DCL

access

Data Control Language. Grants or removes the ability for a role or user to run other statements against an object.

GRANTREVOKE

TCL

unit of work

Transaction Control Language. Groups DML statements into a unit that either fully applies or fully reverts, and marks checkpoints inside that unit.

BEGINCOMMITROLLBACKSAVEPOINT

Quick reference

GroupActs onAuto-commits?Rollback-able?
DDLSchema objectsYes, in most enginesNo — treat as permanent
DQLResult setsn/a (read-only)n/a
DMLRow dataOnly if autocommit mode is onYes, inside an open transaction
DCLPrivilegesYes, in most enginesEngine-dependent
TCLThe transaction itselfn/an/a

DDL — shaping the schema

creating and changing a table
CREATE TABLE employees (
  id          INT PRIMARY KEY,
  name        VARCHAR(100) NOT NULL,
  department  VARCHAR(50),
  hired_on    DATE DEFAULT CURRENT_DATE
);

ALTER TABLE employees ADD COLUMN email VARCHAR(120);
ALTER TABLE employees DROP COLUMN department;
ALTER TABLE employees RENAME COLUMN name TO full_name;

DROP TABLE employees;
TRUNCATE TABLE employees;   -- wipes all rows, resets identity, can't target with WHERE
TRUNCATE vs DELETE — TRUNCATE is DDL: it deallocates the table's storage, skips row-level triggers, and can't be filtered. DELETE is DML: it removes rows one at a time, fires triggers, and can be rolled back inside a transaction.

DQL — the clause order

The order you write a SELECT is not the order it runs. Writing it out helps explain why aliases defined in SELECT aren't visible in WHERE, but are visible in ORDER BY.

written order
SELECT department, COUNT(*) AS headcount
FROM employees
WHERE hired_on >= '2024-01-01'
GROUP BY department
HAVING COUNT(*) > 5
ORDER BY headcount DESC
LIMIT 10;
Execution order — FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. That's why headcount can be used in HAVING/ORDER BY on some engines but not always in WHERE.

DML — changing rows

insert, update, delete, and an upsert
INSERT INTO employees (id, full_name, hired_on)
VALUES (101, 'Asha Rao', '2024-03-01');

UPDATE employees
SET department = 'Engineering'
WHERE id = 101;

DELETE FROM employees
WHERE id = 101;

MERGE INTO targets t USING staging s ON t.id = s.id
WHEN MATCHED THEN UPDATE SET t.value = s.value
WHEN NOT MATCHED THEN INSERT (id, value) VALUES (s.id, s.value);

MERGE syntax varies the most across engines — Postgres uses it since v15; MySQL instead relies on INSERT ... ON DUPLICATE KEY UPDATE for the same upsert pattern.

DCL — permissions

GRANT SELECT, INSERT ON employees TO analyst_role;
GRANT ALL PRIVILEGES ON SCHEMA hr TO admin_role;

REVOKE INSERT ON employees FROM analyst_role;

Grant at the role level, not the individual user, so access changes when someone changes teams rather than when someone remembers to update a permissions list.

TCL — units of work

BEGIN TRANSACTION;

UPDATE accounts SET balance = balance - 100 WHERE id = 1;
SAVEPOINT before_credit;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;

COMMIT;
-- or: ROLLBACK;
-- or: ROLLBACK TO SAVEPOINT before_credit;

A savepoint lets you undo part of a transaction without discarding everything since BEGIN — useful when a multi-step batch fails partway through.

Core toolkit

The vocabulary that shows up in nearly every query: types, operators, the four function families, joins, pivot/unpivot, and what commit/rollback actually guarantee.

Data types

CategoryCommon typesNotes
NumericINT / BIGINT / SMALLINTExact, fixed-size integers.
DECIMAL(p,s) / NUMERIC(p,s)Exact fixed-point — always use for money.
FLOAT / REAL / DOUBLEApproximate — never use for currency.
StringCHAR(n)Fixed-length, space-padded.
VARCHAR(n)Variable-length up to n.
TEXTUnbounded / large text.
Date & timeDATE / TIMECalendar date or time-of-day only.
TIMESTAMP / DATETIMEDate + time; check whether it's timezone-aware.
INTERVALA span of time — Postgres/Oracle; MySQL lacks a native type.
BooleanBOOLEANTrue/false — MySQL stores as TINYINT(1).
Binary / semi-structuredBLOB / BYTEARaw binary data.
JSON / JSONBSemi-structured — JSONB (Postgres) is indexable.

Operators

TypeOperators
Arithmetic+ - * / %
Comparison= <> / != > < >= <=
LogicalAND OR NOT
Membership & patternIN BETWEEN LIKE IS NULL EXISTS
Set operators (combine queries)UNION UNION ALL INTERSECT EXCEPT / MINUS
UNION vs UNION ALL — plain UNION de-duplicates the combined result, which means a sort or hash pass over everything. UNION ALL keeps duplicates and skips that pass — use it whenever you already know the two sides don't overlap.

Functions, by family

Math

ABS(-4)          -- 4
ROUND(3.456, 2)  -- 3.46
CEIL(4.1)        -- 5
FLOOR(4.9)       -- 4
POWER(2, 10)     -- 1024
SQRT(81)         -- 9
MOD(17, 5)       -- 2

String

CONCAT(first, ' ', last)
SUBSTRING(name FROM 1 FOR 3)
LENGTH(name)
UPPER(name) / LOWER(name)
TRIM('  padded  ')
REPLACE(url, 'http:', 'https:')
LPAD(id::text, 6, '0')

Date & time

CURRENT_DATE / CURRENT_TIMESTAMP
DATE_ADD(hired_on, INTERVAL 30 DAY)   -- MySQL
hired_on + INTERVAL '30 days'         -- Postgres
DATEDIFF(day, hired_on, CURRENT_DATE) -- SQL Server
EXTRACT(YEAR FROM hired_on)
DATE_TRUNC('month', hired_on)         -- Postgres

Aggregate

COUNT(*) / COUNT(DISTINCT col)
SUM(amount)
AVG(amount)
MIN(amount) / MAX(amount)
STRING_AGG(name, ', ')     -- Postgres/SQL Server
GROUP_CONCAT(name)         -- MySQL
Aggregates only combine with GROUP BY correctly — every non-aggregated column in SELECT must appear in GROUP BY, or the engine either errors (Postgres) or silently picks an arbitrary row (older MySQL).

Joins

INNER JOIN

Only rows matching on both sides.

LEFT JOIN

All of the left table, matched rows from the right, NULLs where absent.

RIGHT JOIN

Mirror of LEFT JOIN — all of the right table.

FULL OUTER JOIN

Everything from both sides, matched where possible.

CROSS JOIN

Every row of A paired with every row of B — a Cartesian product.

SELF JOIN

A table joined to itself, usually to compare rows within it.

the same relationship, five ways
-- INNER: only customers who have placed an order
SELECT c.name, o.total
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id;

-- LEFT: every customer, with NULLs for those who haven't ordered
SELECT c.name, o.total
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id;

-- ANTI-JOIN pattern: customers with NO orders at all
SELECT c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;

-- SELF JOIN: employees paired with their manager
SELECT e.full_name AS employee, m.full_name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;

Pivot & unpivot

Pivoting turns row values into columns (long → wide); unpivoting does the reverse (wide → long). Syntax is the least standardized part of SQL here — some engines have dedicated keywords, others lean on conditional aggregation.

Pivot with conditional aggregation

Portable across almost every engine.

SELECT
  department,
  SUM(CASE WHEN quarter = 'Q1' THEN revenue END) AS q1,
  SUM(CASE WHEN quarter = 'Q2' THEN revenue END) AS q2,
  SUM(CASE WHEN quarter = 'Q3' THEN revenue END) AS q3,
  SUM(CASE WHEN quarter = 'Q4' THEN revenue END) AS q4
FROM sales
GROUP BY department;

Native PIVOT (SQL Server / Snowflake)

Cleaner syntax, narrower support.

SELECT department, [Q1], [Q2], [Q3], [Q4]
FROM sales
PIVOT (
  SUM(revenue) FOR quarter IN ([Q1], [Q2], [Q3], [Q4])
) AS p;
unpivot — wide back to long
SELECT department, quarter, revenue
FROM sales
UNPIVOT (
  revenue FOR quarter IN (q1, q2, q3, q4)
) AS u;
Postgres note — there's no native PIVOT/UNPIVOT; the conditional-aggregation pattern (or the crosstab() function from the tablefunc extension) does the same job.

Commit & rollback

Whether a DML statement takes effect immediately depends on the connection's autocommit setting. With autocommit off, nothing is durable until COMMIT runs; ROLLBACK discards everything back to the last commit or savepoint.

Isolation levelDirty readNon-repeatable readPhantom read
READ UNCOMMITTEDPossiblePossiblePossible
READ COMMITTEDPreventedPossiblePossible
REPEATABLE READPreventedPreventedPossible (engine-dependent)
SERIALIZABLEPreventedPreventedPrevented

Higher isolation buys correctness at the cost of concurrency — SERIALIZABLE can force transactions to retry when they'd otherwise conflict.

Advanced objects

Views, subqueries, CTEs, window functions, stored procedures, triggers, and the transaction guarantees underneath all of it.

Views

A view is a stored SELECT that behaves like a read-only (or sometimes updatable) table — useful for hiding join complexity, restricting which columns a role can see, or reusing a common query shape.

CREATE VIEW active_employees AS
SELECT id, full_name, department
FROM employees
WHERE terminated_on IS NULL;

CREATE OR REPLACE VIEW active_employees AS ...
DROP VIEW active_employees;
Materialized views store the result physically and need an explicit REFRESH — trade freshness for speed on expensive aggregations.

Subqueries

Non-correlated

Runs once, independent of the outer query.

SELECT full_name
FROM employees
WHERE department IN (
  SELECT id FROM departments
  WHERE region = 'APAC'
);

Correlated

References the outer row, so it re-runs per row.

SELECT e.full_name
FROM employees e
WHERE e.salary > (
  SELECT AVG(e2.salary)
  FROM employees e2
  WHERE e2.department = e.department
);

Subqueries can sit in SELECT (scalar), FROM (as a derived table), or WHERE — a correlated one in WHERE is easy to write but often slower than the equivalent JOIN or window function.

CTEs (WITH clause)

A common table expression names a subquery so it can be referenced once and read top-to-bottom, instead of nesting derived tables inside FROM. Most engines don't materialize a CTE by default — it's inlined like a view, so it isn't automatically faster than the equivalent subquery.

a plain CTE
WITH dept_avg AS (
  SELECT department, AVG(salary) AS avg_salary
  FROM employees
  GROUP BY department
)
SELECT e.full_name, e.salary, d.avg_salary
FROM employees e
JOIN dept_avg d ON d.department = e.department
WHERE e.salary > d.avg_salary;
a recursive CTE — walking a manager hierarchy
WITH RECURSIVE org_chart AS (
  SELECT id, full_name, manager_id, 1 AS depth
  FROM employees
  WHERE manager_id IS NULL          -- anchor: the top of the tree

  UNION ALL

  SELECT e.id, e.full_name, e.manager_id, oc.depth + 1
  FROM employees e
  JOIN org_chart oc ON e.manager_id = oc.id   -- recursive step
)
SELECT * FROM org_chart ORDER BY depth;
Anchor + recursive step — a recursive CTE always has two parts joined by UNION ALL: a non-recursive anchor query, and a recursive query that references the CTE's own name until it returns no more rows.

Window functions

A window function computes across a set of rows related to the current one — via PARTITION BY — without collapsing them into one row the way GROUP BY does.

SELECT
  full_name,
  department,
  salary,
  RANK()       OVER (PARTITION BY department ORDER BY salary DESC) AS dept_rank,
  ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS row_num,
  LAG(salary)  OVER (PARTITION BY department ORDER BY salary DESC) AS next_lowest,
  SUM(salary)  OVER (PARTITION BY department
                      ORDER BY salary DESC
                      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
FROM employees;
FunctionReturns
ROW_NUMBER()Sequential number, no ties.
RANK()Same rank for ties, then skips numbers.
DENSE_RANK()Same rank for ties, no skipped numbers.
NTILE(n)Splits rows into n roughly equal buckets.
LAG() / LEAD()Value from a previous / following row in the same partition.

Stored procedures

A named, precompiled block of SQL and control-flow logic, called rather than sent as a fresh statement each time. Syntax varies more than anything else in SQL — this is Postgres' PL/pgSQL shape.

CREATE PROCEDURE give_raise(emp_id INT, pct NUMERIC)
LANGUAGE plpgsql AS $$
BEGIN
  UPDATE employees
  SET salary = salary * (1 + pct / 100)
  WHERE id = emp_id;
END;
$$;

CALL give_raise(101, 5);
Procedure vs function — a function must return a value and can be used inside a SELECT; a procedure doesn't have to return anything and is invoked with CALL, which makes it the natural home for multi-step operations, including transaction control.

Triggers

Code that fires automatically around an INSERT, UPDATE, or DELETE — before or after the row change is applied. Common uses: audit logging, enforcing a rule a CHECK constraint can't express, keeping a derived column in sync.

CREATE OR REPLACE FUNCTION log_salary_change()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO salary_audit (emp_id, old_salary, new_salary, changed_at)
  VALUES (OLD.id, OLD.salary, NEW.salary, NOW());
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_salary_audit
AFTER UPDATE OF salary ON employees
FOR EACH ROW
EXECUTE FUNCTION log_salary_change();
Trigger cost — they run per row, hide business logic outside application code, and are easy to forget about during a bulk load. Use sparingly, and document them where the schema itself is documented.

Transactions & ACID

Atomicity

All statements in the transaction apply, or none do.

Consistency

Every commit leaves the database satisfying its constraints.

Isolation

Concurrent transactions don't see each other's uncommitted work (to the degree the isolation level guarantees).

Durability

Once committed, the change survives a crash.

Design & security

How to shape a schema that holds up: normalization, ER modeling, data shapes at system scale, everyday best practices, defending against SQL injection, and planning for backups and scale.

Best practices

PracticeWhy
Name tables/columns consistentlyReduces guesswork across a growing schema.
Index foreign keys and filter/sort columnsThese are exactly the columns WHERE, JOIN, and ORDER BY lean on.
Avoid SELECT * in application codeA later column addition silently changes the result shape.
Enforce constraints in the schemaPRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK, and NOT NULL catch bad data before the app has to.
Read the query planEXPLAIN / EXPLAIN ANALYZE shows whether an index is actually used.
Pool connectionsOpening a new connection per request is one of the most common causes of database load under traffic.

Normalization

Each normal form removes a specific kind of redundancy. Most application schemas stop at 3NF; analytics tables often denormalize deliberately for read speed.

1NF

Rule: every column holds a single, atomic value — no repeating groups or comma-packed lists in one cell.

Fix: a "phone_numbers" column holding "555-1234, 555-5678" becomes its own row per number in a related table.

2NF

Rule: 1NF, plus every non-key column depends on the whole primary key — relevant only when the key is composite.

Fix: in an order_items table keyed on (order_id, product_id), a product_name column depends only on product_id — move it to a products table.

3NF

Rule: 2NF, plus no non-key column depends on another non-key column (no transitive dependency).

Fix: an employees table storing both department_id and department_name should drop department_name — it depends on department_id, not on the employee.

BCNF

Rule: a stricter 3NF — every determinant (anything another column fully depends on) must itself be a candidate key.

Fix: applies mainly when a table has multiple overlapping candidate keys; split so each determinant becomes its own key.

Denormalization is a trade, not a mistake — a reporting table that duplicates department_name alongside department_id avoids a join on every dashboard query, at the cost of updating two places when a department is renamed.

ER modeling

Entities become tables, attributes become columns, and relationships become foreign keys — except many-to-many, which needs a junction table in between.

Customer id · name Order id · customer_id Order_Item order_id · product_id Product id · name · price 1 : N 1 : N N : 1

Order and Product have a many-to-many relationship — one order can contain many products, and one product appears on many orders — resolved through the Order_Item junction table, which turns it into two one-to-many relationships.

Data shapes & modeling

Beyond normalizing a single schema, how tables relate to each other at a system level depends on what the data is for — transactional systems and analytical systems tend to converge on different shapes.

Wide vs. long (tidy) data

Wide format gives each measured variable its own column (one row per entity). Long format stacks measurements into key/value-style rows, which most analytics and plotting tools expect.

-- wide: one row per employee
-- id | q1_sales | q2_sales | q3_sales

-- long: one row per (employee, quarter)
-- id | quarter | sales

OLTP vs. OLAP shape

OLTP schemas favor normalization for fast, safe writes. OLAP schemas favor wider, denormalized fact tables for fast reads over large aggregations.

OLTPOLAP
Optimized forWritesReads / aggregation
ShapeNormalized (3NF)Star / snowflake
ExampleOrder-processing DBSales data warehouse

Star schema

A central fact table (the events being measured — orders, clicks, sales) with foreign keys out to surrounding dimension tables (who, what, when, where) that describe those events. A snowflake schema is the same idea with dimensions further normalized into sub-dimensions.

-- fact table: one row per line item sold
fact_sales(sale_id, date_id, customer_id, product_id, store_id, quantity, revenue)

-- dimension tables describe the "who/what/when/where"
dim_date(date_id, day, month, quarter, year)
dim_customer(customer_id, name, region)
dim_product(product_id, name, category)
dim_store(store_id, city, country)
Why denormalize dimensions here — a dashboard querying millions of fact rows can't afford deep joins across a fully normalized schema; a star schema trades some redundancy in the dimension tables for queries that touch far fewer tables.

SQL injection

Injection happens when untrusted input is concatenated directly into a SQL string, letting the input change the query's structure instead of just supplying a value.

Vulnerable pattern

-- application code builds this string directly
"SELECT * FROM users WHERE username = '" + input + "'"

Parameterized fix

SELECT * FROM users WHERE username = ?;
-- driver binds `input` as a value, never as SQL text
Defense layerWhat it does
Parameterized queries / bind variablesInput is always treated as data, never as executable SQL — the primary defense.
ORM query buildersParameterize under the hood, so long as raw-SQL escape hatches aren't used with string concatenation.
Least-privilege DB accountsAn app account with only the permissions it needs limits the damage of any successful injection.
Input validationRejects clearly malformed input early — a second layer, not a replacement for parameterization.
Web application firewallBlocks known attack patterns at the network edge.

Backups & scalable architecture

Backup typeWhat it captures
FullThe entire database at a point in time — simplest to restore, largest and slowest to take.
IncrementalOnly what changed since the last backup of any kind — smallest, but a restore chains several files together.
DifferentialEverything changed since the last full backup — a middle ground between the two.
Continuous (WAL / binlog)A running log of every change, enabling point-in-time recovery to any moment, not just a backup checkpoint.

Two numbers frame the plan: RPO (recovery point objective — how much recent data you can afford to lose) and RTO (recovery time objective — how long you can afford to be down).

Scaling paths

StrategyTrade-off
Vertical scalingSimplest — bigger machine, no application changes — but has a ceiling and a single point of failure.
Read replicasSpreads read load; writes still funnel through one primary, and replicas lag slightly behind it.
Sharding / partitioningSplits data across nodes by key — scales writes too, at the cost of cross-shard queries becoming harder.
Caching layerAbsorbs repeat reads before they reach the database — needs an explicit invalidation strategy.
Connection poolingReuses a fixed set of connections instead of opening one per request — cheap, and usually the first fix under load.