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
structureData 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.
DQL
retrievalData Query Language. Technically one statement — SELECT — but its clauses (filtering, grouping, ordering, limiting) do most of the heavy lifting in day-to-day SQL.
DML
row dataData 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.
DCL
accessData Control Language. Grants or removes the ability for a role or user to run other statements against an object.
TCL
unit of workTransaction Control Language. Groups DML statements into a unit that either fully applies or fully reverts, and marks checkpoints inside that unit.
Quick reference
| Group | Acts on | Auto-commits? | Rollback-able? |
|---|---|---|---|
| DDL | Schema objects | Yes, in most engines | No — treat as permanent |
| DQL | Result sets | n/a (read-only) | n/a |
| DML | Row data | Only if autocommit mode is on | Yes, inside an open transaction |
| DCL | Privileges | Yes, in most engines | Engine-dependent |
| TCL | The transaction itself | n/a | n/a |
DDL — shaping the schema
creating and changing a tableCREATE 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
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 orderSELECT department, COUNT(*) AS headcount
FROM employees
WHERE hired_on >= '2024-01-01'
GROUP BY department
HAVING COUNT(*) > 5
ORDER BY headcount DESC
LIMIT 10;
headcount can be used in HAVING/ORDER BY on some engines but not always in WHERE.DML — changing rows
insert, update, delete, and an upsertINSERT 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
| Category | Common types | Notes |
|---|---|---|
| Numeric | INT / BIGINT / SMALLINT | Exact, fixed-size integers. |
DECIMAL(p,s) / NUMERIC(p,s) | Exact fixed-point — always use for money. | |
FLOAT / REAL / DOUBLE | Approximate — never use for currency. | |
| String | CHAR(n) | Fixed-length, space-padded. |
VARCHAR(n) | Variable-length up to n. | |
TEXT | Unbounded / large text. | |
| Date & time | DATE / TIME | Calendar date or time-of-day only. |
TIMESTAMP / DATETIME | Date + time; check whether it's timezone-aware. | |
INTERVAL | A span of time — Postgres/Oracle; MySQL lacks a native type. | |
| Boolean | BOOLEAN | True/false — MySQL stores as TINYINT(1). |
| Binary / semi-structured | BLOB / BYTEA | Raw binary data. |
JSON / JSONB | Semi-structured — JSONB (Postgres) is indexable. |
Operators
| Type | Operators |
|---|---|
| Arithmetic | + - * / % |
| Comparison | = <> / != > < >= <= |
| Logical | AND OR NOT |
| Membership & pattern | IN BETWEEN LIKE IS NULL EXISTS |
| Set operators (combine queries) | UNION UNION ALL INTERSECT EXCEPT / MINUS |
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
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.
-- 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;
SELECT department, quarter, revenue
FROM sales
UNPIVOT (
revenue FOR quarter IN (q1, q2, q3, q4)
) AS u;
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 level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| READ UNCOMMITTED | Possible | Possible | Possible |
| READ COMMITTED | Prevented | Possible | Possible |
| REPEATABLE READ | Prevented | Prevented | Possible (engine-dependent) |
| SERIALIZABLE | Prevented | Prevented | Prevented |
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;
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 CTEWITH 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;
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;
| Function | Returns |
|---|---|
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);
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();
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
| Practice | Why |
|---|---|
| Name tables/columns consistently | Reduces guesswork across a growing schema. |
| Index foreign keys and filter/sort columns | These are exactly the columns WHERE, JOIN, and ORDER BY lean on. |
Avoid SELECT * in application code | A later column addition silently changes the result shape. |
| Enforce constraints in the schema | PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK, and NOT NULL catch bad data before the app has to. |
| Read the query plan | EXPLAIN / EXPLAIN ANALYZE shows whether an index is actually used. |
| Pool connections | Opening 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.
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.
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.
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.
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.
ER modeling
Entities become tables, attributes become columns, and relationships become foreign keys — except many-to-many, which needs a junction table in between.
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.
| OLTP | OLAP | |
|---|---|---|
| Optimized for | Writes | Reads / aggregation |
| Shape | Normalized (3NF) | Star / snowflake |
| Example | Order-processing DB | Sales 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)
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 layer | What it does |
|---|---|
| Parameterized queries / bind variables | Input is always treated as data, never as executable SQL — the primary defense. |
| ORM query builders | Parameterize under the hood, so long as raw-SQL escape hatches aren't used with string concatenation. |
| Least-privilege DB accounts | An app account with only the permissions it needs limits the damage of any successful injection. |
| Input validation | Rejects clearly malformed input early — a second layer, not a replacement for parameterization. |
| Web application firewall | Blocks known attack patterns at the network edge. |
Backups & scalable architecture
| Backup type | What it captures |
|---|---|
| Full | The entire database at a point in time — simplest to restore, largest and slowest to take. |
| Incremental | Only what changed since the last backup of any kind — smallest, but a restore chains several files together. |
| Differential | Everything 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
| Strategy | Trade-off |
|---|---|
| Vertical scaling | Simplest — bigger machine, no application changes — but has a ceiling and a single point of failure. |
| Read replicas | Spreads read load; writes still funnel through one primary, and replicas lag slightly behind it. |
| Sharding / partitioning | Splits data across nodes by key — scales writes too, at the cost of cross-shard queries becoming harder. |
| Caching layer | Absorbs repeat reads before they reach the database — needs an explicit invalidation strategy. |
| Connection pooling | Reuses a fixed set of connections instead of opening one per request — cheap, and usually the first fix under load. |