Skip to content

SQL Fundamentals

SQL is defined by ANSI/ISO standards (SQL-86, SQL-89, SQL-92, SQL:1999, SQL:2003, SQL:2006, SQL:2008, SQL:2011, SQL:2016, SQL:2019, SQL:2023). No database implements the full standard. PostgreSQL has the broadest standards compliance among open-source databases. MySQL diverges Significantly. SQLite implements a large subset but omits many features (e.g., RIGHT JOIN, FULL OUTER JOIN were added in 3.39.0, 2022).

When this document specifies behaviour, it defaults to PostgreSQL syntax unless otherwise noted.

DDL defines and modifies the database schema. These statements are transactional in PostgreSQL and SQLite but often auto-commit in MySQL.

CREATE TABLE employees (
emp_id SERIAL PRIMARY KEY,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
hire_date DATE NOT NULL DEFAULT CURRENT_DATE,
salary NUMERIC(10,2) NOT NULL CHECK (salary > 0),
department_id INTEGER REFERENCES departments(dept_id) ON DELETE SET NULL,
CONSTRAINT uq_email UNIQUE (email),
CONSTRAINT chk_salary_range CHECK (salary >= 30000 AND salary <= 1000000)
);

Key elements:

  • SERIAL (PostgreSQL) / AUTO_INCREMENT (MySQL) / INTEGER PRIMARY KEY (SQLite) for auto-generating keys
  • NOT NULL — the column must have a value
  • UNIQUE — no two rows can have the same value in this column
  • CHECK — an arbitrary boolean expression evaluated on insert/update
  • DEFAULT — value used when no explicit value is provided
  • REFERENCES — foreign key constraint with referential action
Type CategoryPostgreSQL TypesNotes
IntegersSMALLINT``INTEGER``BIGINTINTEGER is 4 bytes, BIGINT is 8 bytes
Fixed precisionNUMERIC(p,s)``DECIMAL(p,s)Exact arithmetic; NUMERIC(10,2) holds up to 99,999,999.99
Floating pointREAL``DOUBLE PRECISIONInexact; avoid for financial data
Variable stringVARCHAR(n)``TEXTVARCHAR with length is a constraint, not a storage optimisation in PostgreSQL
Fixed stringCHAR(n)Padded with spaces; rarely useful
BooleanBOOLEANTRUE``FALSE``NULL
Date/TimeDATE``TIME``TIMESTAMP``TIMESTAMPTZTIMESTAMPTZ stores UTC; always prefer it over TIMESTAMP
BinaryBYTEAVariable-length binary data
JSONJSON``JSONBJSONB is stored in decomposed binary form; faster to query
UUIDUUIDRequires the uuid-ossp or pgcrypto extension
ArrayINTEGER[]``TEXT[]PostgreSQL-specific extension
NetworkINET``CIDR``MACADDRPostgreSQL-specific; enforces valid IP/MAC formats

AVG(salary) excludes rows where salary IS NULL from both the sum and the count. If you need to Treat NULL as zero, use AVG(COALESCE(salary, 0))But understand that this changes the semantics: NULL means “unknown,” not “zero.”

SELECT department_id, COUNT(*) AS headcount, AVG(salary) AS avg_salary
FROM employees
GROUP BY department_id
ORDER BY avg_salary DESC;

Every column in the SELECT list must either appear in the GROUP BY clause or be wrapped in an Aggregate function. PostgreSQL is strict about this; MySQL (with ONLY_FULL_GROUP_BY disabled) Allows ambiguous queries.

Filters groups after aggregation. WHERE filters rows before aggregation.

SELECT department_id, AVG(salary) AS avg_salary
FROM employees
WHERE hire_date >= '2022-01-01' -- filter individual rows first
GROUP BY department_id
HAVING AVG(salary) > 100000 -- then filter aggregated groups
ORDER BY avg_salary DESC;

PostgreSQL extensions for multi-level grouping:

-- GROUPING SETS: specify multiple groupings in one query
SELECT department_id, job_title, COUNT(*), AVG(salary)
FROM employees
GROUP BY GROUPING SETS (
(department_id, job_title),
(department_id),
()
);
-- ROLLUP: hierarchical subtotals
SELECT region, country, SUM(revenue)
FROM sales
GROUP BY ROLLUP (region, country);
-- Produces: (region, country), (region), ()
-- CUBE: all possible combinations
SELECT region, channel, product_line, SUM(revenue)
FROM sales
GROUP BY CUBE (region, channel, product_line);
-- Produces: all 8 combinations of 3 columns

Window functions perform a calculation across a set of table rows related to the current row. Unlike Aggregate functions with GROUP BYThey do not collapse rows — every input row produces an output Row.

function_name(args) OVER (
[PARTITION BY partition_expression]
[ORDER BY sort_expression [ASC|DESC] [NULLS {FIRST|LAST}]]
[frame_clause: ROWS|RANGE BETWEEN start AND end]
)
SELECT emp_id, department_id, salary,
ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS row_num,
RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rank,
DENSE_RANK() OVER (PARTITION BY department_id ORDER BY salary DESC) AS dense_rank
FROM employees;
salaryROW_NUMBERRANKDENSE_RANK
150000111
150000211
140000332
140000432
130000553
  • ROW_NUMBER: assigns a unique sequential integer to each row within the partition
  • RANK: ties get the same rank; next rank skips (1, 1, 3, 3, 5)
  • DENSE_RANK: ties get the same rank; next rank does not skip (1, 1, 2, 2, 3)

Access values from preceding or following rows:

SELECT order_date, revenue,
LAG(revenue, 1) OVER (ORDER BY order_date) AS prev_day_revenue,
LEAD(revenue, 1) OVER (ORDER BY order_date) AS next_day_revenue,
revenue - LAG(revenue, 1) OVER (ORDER BY order_date) AS day_over_day_change
FROM daily_sales;
-- Running total:
SELECT order_date, amount,
SUM(amount) OVER (ORDER BY order_date
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total;
-- Moving average (7-day):
SELECT order_date, amount,
AVG(amount) OVER (ORDER BY order_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS ma_7day;
-- Percentage of total:
SELECT department_id, salary,
salary / SUM(salary) OVER (PARTITION BY department_id) AS pct_of_dept_salary
FROM employees;
-- First and last value in partition:
SELECT emp_id, department_id, hire_date,
FIRST_VALUE(hire_date) OVER (PARTITION BY department_id ORDER BY hire_date) AS earliest_hire,
LAST_VALUE(hire_date) OVER (
PARTITION BY department_id ORDER BY hire_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS latest_hire
FROM employees;