Beginner Intermediate 16 min read

Chapter 41: Relational Databases & SQL Fundamentals

Data lives in tables. From the earliest days of computing through today's cloud-scale analytical warehouses, the relational model has proven to be the most durable abstraction in all of data engineering. Every data scientist, machine learning engineer, and analyst eventually reaches a point where the data they need is stored in a relational database — and SQL is the key that unlocks it.

This chapter builds a solid, practitioner-grade foundation in relational databases and SQL. We start with the relational model itself: why it was invented, what problems it solves, and how tables, keys, and normalization give data integrity almost for free. We then move to the SELECT statement in full — filtering rows with WHERE, eliminating duplicates with DISTINCT, ordering results, calling built-in scalar functions, and handling the notorious NULLs that trip up even experienced engineers. We close with the DDL and DML statements you need to actually create and populate tables in a real PostgreSQL database.

Every concept is grounded in runnable SQL against small, concrete sample tables. By the end of the chapter you will be comfortable reading and writing production queries, reasoning about why a query returns the rows it does, and avoiding the most common pitfalls that corrupt results silently.

Runnable companion notebook for this chapter. Download the Jupyter notebook (.ipynb)
Libraries covered: PostgreSQLSQLAlchemy

Learning Objectives

  • Explain the relational model — relations, tuples, attributes, and domain constraints — and contrast it with flat-file storage.
  • Define primary keys, foreign keys, and the three normal forms (1NF, 2NF, 3NF), and apply them to a simple schema design.
  • Write SELECT queries that filter (WHERE), sort (ORDER BY), and deduplicate (DISTINCT) rows from one or more tables.
  • Use built-in scalar functions for strings, numbers, and dates, and understand how NULL propagates through expressions and comparisons.
  • Apply aggregate functions (COUNT, SUM, AVG, MIN, MAX) with GROUP BY and HAVING to produce summary statistics.
  • Write DDL statements (CREATE TABLE, ALTER TABLE, DROP TABLE) and DML statements (INSERT, UPDATE, DELETE) to manage schema and data.
  • Identify common pitfalls — NULL comparisons, implicit type coercions, and non-deterministic ORDER BY — and write queries that avoid them.

41.1 The Relational Model: Tables, Keys, and Normalization Beginner

Why the Relational Model?

E.F. Codd introduced the relational model in 1970 in his landmark paper A Relational Model of Data for Large Shared Data Banks. His central insight was that data should be described by its content — the values it holds — rather than by pointers or navigational paths. A relation is a set of tuples (rows), each conforming to a fixed set of attributes (columns), where every attribute draws its values from a named domain (data type). The word "table" is the everyday synonym for relation.

A relation has three defining properties:

  • No duplicate rows. A relation is a mathematical set, so every row must be uniquely identifiable.
  • No ordering of rows. The physical storage order carries no semantic meaning. Ordering is a query-time concern.
  • Each cell holds one atomic value. You cannot store a list inside a single cell and still remain in first normal form.

These constraints are not bureaucratic rules; they are what makes querying predictable and optimization possible.

Keys

A candidate key is a minimal set of attributes whose values uniquely identify every row in a relation. "Minimal" means you cannot remove any attribute and still have uniqueness. From the candidate keys you choose one to be the primary key (PK) — the official row identifier. In PostgreSQL, declaring a column PRIMARY KEY creates a unique index and rejects NULLs automatically.

A foreign key (FK) is one or more columns in a "child" table whose values must match a primary key (or unique key) in a "parent" table — or be NULL. This referential integrity constraint is the mechanical enforcement of relationships between entities.

SQL
CREATE TABLE customers (
    customer_id  SERIAL PRIMARY KEY,
    email        TEXT    NOT NULL UNIQUE,
    full_name    TEXT    NOT NULL,
    country_code CHAR(2) NOT NULL
);

CREATE TABLE orders (
    order_id    SERIAL PRIMARY KEY,
    customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
    order_date  DATE    NOT NULL DEFAULT CURRENT_DATE,
    total_usd   NUMERIC(12, 2) NOT NULL CHECK (total_usd >= 0)
);

The REFERENCES clause tells PostgreSQL: every customer<em>id stored in orders must exist in customers. Attempts to insert a row with a nonexistent customer will raise a foreign-key violation error immediately, before any row is committed.

Normalization

Normalization is the process of restructuring a schema to reduce data redundancy and the update anomalies that redundancy causes. The three most important normal forms are:

First Normal Form (1NF)

Every column holds atomic, indivisible values, and there are no repeating groups. A column storing comma-separated tags (&quot;python,sql,ml&quot;) violates 1NF. Fix it by creating a separate item</em>tags table with one row per tag.

Second Normal Form (2NF)

A table is in 2NF if it is in 1NF and every non-key attribute is fully functionally dependent on the entire primary key. 2NF violations only arise when the PK is composite. If order<em>id, product</em>id is the PK of an order<em>lines table but product</em>name depends only on product<em>id, move product</em>name to a products table.

Third Normal Form (3NF)

A table is in 3NF if it is in 2NF and there are no transitive dependencies — no non-key column determines another non-key column. If zip<em>code determines city and state, storing all three in a customers table creates a transitive dependency. The fix is a zip</em>codes lookup table.

Why It Matters in Practice

In OLTP (Online Transaction Processing) systems, normalization prevents a single fact from being stored in multiple places, which means an UPDATE only needs to touch one row. In analytical (OLAP) workloads, schemas are often intentionally denormalized (star schema, wide tables) because read performance and query simplicity outweigh update cost. Understanding normalization lets you make that tradeoff deliberately rather than by accident.

A Working Sample Schema

The rest of this chapter uses these four tables. Run the DDL to follow along:

SQL
CREATE TABLE products (
    product_id   SERIAL PRIMARY KEY,
    name         TEXT           NOT NULL,
    category     TEXT           NOT NULL,
    price_usd    NUMERIC(10,2)  NOT NULL,
    in_stock     BOOLEAN        NOT NULL DEFAULT TRUE
);

INSERT INTO products (name, category, price_usd) VALUES
  ('Wireless Keyboard', 'Electronics',  49.99),
  ('USB-C Hub',         'Electronics',  29.99),
  ('Standing Desk',     'Furniture',   349.00),
  ('Ergonomic Chair',   'Furniture',   499.00),
  ('Notebook (A5)',     'Stationery',    4.99),
  ('Ballpoint Pens',   'Stationery',    7.49),
  ('Monitor Arm',      'Electronics',  89.95);

Code Examples

Check Primary Key and Foreign Key Constraints in psql

Inspect all PK and FK constraints in a PostgreSQL database using the information_schema views.

SQL
-- List all table constraints in the public schema
SELECT
    tc.table_name,
    tc.constraint_name,
    tc.constraint_type,
    kcu.column_name,
    ccu.table_name  AS foreign_table,
    ccu.column_name AS foreign_column
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
  ON tc.constraint_name = kcu.constraint_name
LEFT JOIN information_schema.constraint_column_usage ccu
  ON tc.constraint_name = ccu.constraint_name
WHERE tc.table_schema = 'public'
  AND tc.constraint_type IN ('PRIMARY KEY', 'FOREIGN KEY')
ORDER BY tc.table_name, tc.constraint_type;
Output
 table_name | constraint_name  | constraint_type | column_name | foreign_table | foreign_column
------------+------------------+-----------------+-------------+---------------+----------------
 customers  | customers_pkey   | PRIMARY KEY     | customer_id |               |
 orders     | orders_customer_ | FOREIGN KEY     | customer_id | customers     | customer_id
 orders     | orders_pkey      | PRIMARY KEY     | order_id    |               |

Demonstrate a Foreign Key Violation

Show how PostgreSQL enforces referential integrity at the database level, not just the application level.

SQL
-- This will raise: ERROR: insert or update on table "orders"
-- violates foreign key constraint
INSERT INTO orders (customer_id, total_usd)
VALUES (9999, 100.00);  -- customer 9999 does not exist

Connect to PostgreSQL with psycopg2 and Inspect Tables

Minimal Python snippet to connect to a local PostgreSQL database and list all user-created tables.

PYTHON
import psycopg2

conn = psycopg2.connect(
    host="localhost",
    dbname="textbook",
    user="postgres",
    password="secret"
)
cur = conn.cursor()

cur.execute("""
    SELECT table_name
    FROM information_schema.tables
    WHERE table_schema = 'public'
    ORDER BY table_name;
""")

for row in cur.fetchall():
    print(row[0])

cur.close()
conn.close()
Output
customers
order_lines
orders
products

41.2 The SELECT Statement: Projections, Expressions, and Aliases Beginner

Anatomy of a SELECT Statement

The SELECT statement is the core of SQL. Its logical evaluation order differs from its written order, and misunderstanding that order is the root cause of many confusing error messages.

The written order is:

SELECT  ...
FROM    ...
WHERE   ...
GROUP BY ...
HAVING  ...
ORDER BY ...
LIMIT   ...

The logical evaluation order is: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. The implication is that column aliases defined in SELECT cannot be referenced in WHERE, because WHERE is evaluated before SELECT. PostgreSQL does allow aliases in ORDER BY as a convenience extension, but not all databases do.

Projections and Column Expressions

A projection selects a subset of columns from a table. Use SELECT <em> during exploration, but name columns explicitly in production code — schema changes silently break </em> queries.

SQL
-- All columns (exploration only)
SELECT * FROM products;

-- Named projection
SELECT product_id, name, price_usd FROM products;

Columns can be any valid expression, not just bare column names:

SQL
SELECT
    name,
    price_usd,
    price_usd * 1.08               AS price_with_tax,
    UPPER(category)                AS category_upper,
    LENGTH(name)                   AS name_length
FROM products;

Column Aliases

The AS keyword gives a column expression a readable name in the result set. AS is optional — price<em>usd * 1.08 price</em>with_tax also works — but omitting it makes queries harder to read. Alias names that contain spaces or capital letters must be double-quoted: AS &quot;Price (USD)&quot;. Single-quoted aliases are a syntax error in standard SQL.

DISTINCT

SELECT DISTINCT eliminates duplicate rows from the result set. It operates on the entire row as returned by the projection, not just the first column.

SQL
-- How many unique categories exist?
SELECT DISTINCT category FROM products ORDER BY category;

  category
-------------
 Electronics
 Furniture
 Stationery

DISTINCT is often overused as a band-aid for duplicates that should have been prevented by proper joins or aggregation. If you are surprised to need DISTINCT, investigate whether your join is producing a Cartesian product.

Ordering Results

ORDER BY sorts the result. Specify ASC (default) or DESC per column. You can sort by column name, column alias, or ordinal position (though ordinal position is fragile and discouraged).

SQL
SELECT name, category, price_usd
FROM products
ORDER BY category ASC, price_usd DESC;

This sorts alphabetically by category first, then by price descending within each category. NULL values sort last in ascending order and first in descending order by default in PostgreSQL. Use NULLS FIRST or NULLS LAST to override:

SQL
ORDER BY price_usd DESC NULLS LAST

LIMIT and OFFSET

LIMIT n returns at most n rows. OFFSET k skips the first k rows. Together they implement pagination:

SQL
-- Page 2 of results, 3 rows per page
SELECT product_id, name, price_usd
FROM products
ORDER BY product_id
LIMIT 3 OFFSET 3;

Pitfall: LIMIT without ORDER BY returns a non-deterministic subset. Two identical queries can return different rows if the query planner chooses different access paths. Always pair LIMIT with ORDER BY in production.

Computed Columns and Type Casting

PostgreSQL uses :: for casting and the standard CAST(x AS type) syntax:

SQL
SELECT
    name,
    price_usd::INTEGER              AS price_rounded_down,
    CAST(price_usd AS TEXT)         AS price_text,
    ROUND(price_usd, 0)             AS price_nearest_dollar
FROM products;

Be aware that ::INTEGER truncates (rounds toward zero), while ROUND() rounds to nearest. These produce different results for values like 49.7.

Code Examples

Full Projection with Multiple Expressions

Demonstrate arithmetic expressions, string concatenation, type casting, and a CASE expression in a single SELECT.

SQL
SELECT
    product_id,
    name,
    category,
    price_usd,
    price_usd * 0.9                    AS sale_price,
    CONCAT('SKU-', product_id::TEXT)   AS sku,
    CASE WHEN price_usd > 100
         THEN 'premium'
         ELSE 'standard'
    END                                AS tier
FROM products
ORDER BY price_usd DESC;
Output
 product_id |       name        |  category   | price_usd | sale_price |   sku   |   tier
-----------+-------------------+-------------+-----------+------------+---------+----------
         4 | Ergonomic Chair   | Furniture   |    499.00 |     449.10 | SKU-4   | premium
         3 | Standing Desk     | Furniture   |    349.00 |     314.10 | SKU-3   | premium
         7 | Monitor Arm       | Electronics |     89.95 |      80.96 | SKU-7   | standard
         1 | Wireless Keyboard | Electronics |     49.99 |      44.99 | SKU-1   | standard

DISTINCT with Multiple Columns

Show that DISTINCT applies to the entire projected row, not a single column.

SQL
-- Unique (category, in_stock) combinations
SELECT DISTINCT category, in_stock
FROM products
ORDER BY category, in_stock;

Fetch Query Results into a Pandas DataFrame

Use pandas read_sql_query to pull a SELECT result directly into a DataFrame — the standard pattern for data science workflows.

PYTHON
import pandas as pd
import psycopg2

conn = psycopg2.connect(
    host="localhost", dbname="textbook",
    user="postgres", password="secret"
)

query = """
    SELECT
        name,
        category,
        price_usd,
        price_usd * 1.08 AS price_with_tax
    FROM products
    ORDER BY category, name;
"""

df = pd.read_sql_query(query, conn)
conn.close()

print(df.to_string(index=False))
Output
                name     category  price_usd  price_with_tax
      Wireless Keyboard  Electronics      49.99         53.9892
             USB-C Hub   Electronics      29.99         32.3892
          Monitor Arm    Electronics      89.95         97.1460
       Ergonomic Chair    Furniture      499.00        538.9200
        Standing Desk     Furniture      349.00        376.9200
       Ballpoint Pens    Stationery        7.49          8.0892
         Notebook (A5)   Stationery        4.99          5.3892

41.3 Filtering with WHERE and NULL Handling Beginner

The WHERE Clause

WHERE filters the rows returned by a query. Only rows for which the WHERE condition evaluates to TRUE are included in the result. This sounds trivial, but the three-valued logic introduced by NULL makes it subtler than it appears.

Comparison Operators

PostgreSQL supports the standard comparison operators: =, &lt;&gt; (or !=), &lt;, &lt;=, &gt;, &gt;=. These work on numbers, strings, and dates:

SQL
-- Products priced below $50
SELECT name, price_usd FROM products WHERE price_usd < 50;

-- Electronics category only
SELECT name, price_usd FROM products WHERE category = 'Electronics';

-- Not furniture
SELECT name, category FROM products WHERE category <> 'Furniture';

Logical Operators: AND, OR, NOT

Combine conditions with AND, OR, and NOT. AND has higher precedence than OR — always use parentheses when mixing them to make intent explicit:

SQL
-- Electronics OR items under $10 (parentheses clarify grouping)
SELECT name, category, price_usd
FROM products
WHERE category = 'Electronics'
   OR price_usd < 10;

-- Electronics AND under $50
SELECT name, price_usd
FROM products
WHERE category = 'Electronics'
  AND price_usd < 50;

BETWEEN, IN, LIKE

BETWEEN a AND b is inclusive on both ends — equivalent to &gt;= a AND &lt;= b.

SQL
SELECT name, price_usd FROM products
WHERE price_usd BETWEEN 20 AND 100;

IN (list) is shorthand for multiple OR-equality conditions:

SQL
SELECT name FROM products
WHERE category IN ('Electronics', 'Furniture');

LIKE matches string patterns. % matches any sequence of characters; <em> matches exactly one character. Use ILIKE in PostgreSQL for case-insensitive matching:

SQL
-- Names containing "keyboard" (case-insensitive)
SELECT name FROM products WHERE name ILIKE '%keyboard%';

-- Names starting with exactly 5 characters then a space
SELECT name FROM products WHERE name LIKE '_____ %';

Three-Valued Logic and NULL

NULL in SQL does not mean zero, empty string, or false. NULL means unknown or inapplicable. This single fact is responsible for a disproportionate share of SQL bugs in production systems.

SQL uses three-valued logic: every predicate evaluates to TRUE, FALSE, or UNKNOWN. Any comparison involving NULL evaluates to UNKNOWN:

$$ \text{NULL} = \text{NULL} \Rightarrow \text{UNKNOWN} $$
$$ \text{NULL} <> 5 \Rightarrow \text{UNKNOWN} $$

Because WHERE only admits TRUE rows, UNKNOWN rows are silently excluded. The following query returns zero rows even if NULLs exist in price</em>usd:

SQL
-- WRONG: never matches NULL prices
SELECT * FROM products WHERE price_usd = NULL;

-- CORRECT: use IS NULL
SELECT * FROM products WHERE price_usd IS NULL;

-- CORRECT: non-NULL prices
SELECT * FROM products WHERE price_usd IS NOT NULL;

NULL Propagation in Expressions

NULL propagates through arithmetic and string expressions: NULL + 1 is NULL, NULL || &#039;hello&#039; is NULL (in PostgreSQL). The COALESCE function is the standard tool for substituting a default value:

SQL
-- Return 0.00 if discount_usd is NULL
SELECT name, COALESCE(discount_usd, 0.00) AS discount FROM products;

NULLIF(a, b) returns NULL when a = b, and a otherwise — useful for avoiding division-by-zero:

SQL
-- Avoid division by zero: return NULL instead
SELECT revenue / NULLIF(units_sold, 0) AS avg_revenue_per_unit
FROM sales;

Common NULL Pitfalls

  • COUNT() vs COUNT(col): COUNT(</em>) counts all rows; COUNT(col) counts non-NULL values. They can differ significantly.
  • NOT IN with NULLs: If a subquery in NOT IN returns even one NULL, the entire NOT IN evaluates to UNKNOWN for every row, silently returning zero rows. Prefer NOT EXISTS or filter NULLs explicitly.
  • Aggregates ignore NULLs: AVG(col) averages only the non-NULL rows. If this differs from averaging-over-all-rows (treating NULL as zero), the results will be wrong.

SQL
-- Demonstration: COUNT(*) vs COUNT(column)
-- (Add a NULL discount for illustration)
ALTER TABLE products ADD COLUMN discount_usd NUMERIC(6,2);
UPDATE products SET discount_usd = 5.00 WHERE product_id IN (1, 3);

SELECT
    COUNT(*)             AS total_rows,
    COUNT(discount_usd)  AS rows_with_discount,
    AVG(discount_usd)    AS avg_discount_nonnull
FROM products;
-- total_rows=7, rows_with_discount=2, avg_discount_nonnull=5.00

Code Examples

Comprehensive WHERE Filtering

Combine IN, BETWEEN, and ILIKE with NOT in a realistic multi-condition filter.

SQL
-- Multiple filter techniques in one query
SELECT
    product_id,
    name,
    category,
    price_usd
FROM products
WHERE category IN ('Electronics', 'Stationery')
  AND price_usd BETWEEN 5 AND 60
  AND name NOT ILIKE '%hub%'
ORDER BY category, price_usd;
Output
 product_id |       name        |  category   | price_usd
-----------+-------------------+-------------+-----------
         1 | Wireless Keyboard | Electronics |     49.99
         6 | Ballpoint Pens    | Stationery  |      7.49

NOT IN Trap with NULLs

Demonstrate the NULL-in-NOT-IN trap and the NOT EXISTS workaround — a critical production pitfall.

SQL
-- Setup: a table with a NULL value
CREATE TEMP TABLE demo_null AS
  SELECT generate_series(1,5) AS id, NULL::INTEGER AS ref_id
  UNION ALL
  SELECT 6, 2;

-- This returns zero rows because of the NULL in the list!
SELECT id FROM demo_null
WHERE id NOT IN (SELECT ref_id FROM demo_null);

-- Safe alternative using NOT EXISTS
SELECT id FROM demo_null d1
WHERE NOT EXISTS (
    SELECT 1 FROM demo_null d2
    WHERE d2.ref_id = d1.id
);
Output
-- NOT IN returns: (0 rows)
-- NOT EXISTS returns: 1, 3, 4, 5

COALESCE for NULL-Safe Calculations

Use COALESCE to provide a safe default when performing arithmetic that includes potentially NULL columns.

SQL
-- Compute effective price after discount (treat NULL discount as 0)
SELECT
    name,
    price_usd,
    COALESCE(discount_usd, 0.00)                  AS discount,
    price_usd - COALESCE(discount_usd, 0.00)      AS net_price
FROM products
ORDER BY net_price;

41.4 Scalar Functions and Aggregations with GROUP BY Intermediate

Built-in Scalar Functions

Scalar functions accept one (or a few) values and return one value per row. PostgreSQL ships with hundreds; the ones you will use daily fall into four categories.

String Functions

SQL
SELECT
    name,
    LOWER(name)                        AS name_lower,
    UPPER(category)                    AS cat_upper,
    LENGTH(name)                       AS name_len,
    TRIM('  hello  ')                  AS trimmed,
    SUBSTRING(name FROM 1 FOR 8)       AS first_8,
    REPLACE(name, ' ', '_')            AS name_slug,
    POSITION('Desk' IN name)           AS desk_pos
FROM products
WHERE category = 'Furniture';

CONCAT and the || operator both concatenate strings, but || propagates NULL while CONCAT treats NULL as an empty string — choose intentionally.

Numeric Functions

SQL
SELECT
    price_usd,
    ROUND(price_usd, 1)   AS rounded_1dp,
    CEIL(price_usd)       AS ceiling,
    FLOOR(price_usd)      AS floor,
    ABS(-price_usd)       AS absolute,
    MOD(product_id, 2)    AS parity        -- 0 = even, 1 = odd
FROM products;

Date and Time Functions

Dates are first-class citizens in SQL. PostgreSQL's rich date/time support is one of its standout features:

SQL
SELECT
    CURRENT_DATE                            AS today,
    CURRENT_TIMESTAMP                       AS now,
    EXTRACT(YEAR FROM CURRENT_DATE)         AS current_year,
    EXTRACT(DOW  FROM CURRENT_DATE)         AS day_of_week,  -- 0=Sun
    CURRENT_DATE - INTERVAL '30 days'       AS thirty_ago,
    DATE_TRUNC('month', CURRENT_TIMESTAMP)  AS month_start,
    AGE('2030-01-01'::DATE, CURRENT_DATE)   AS time_to_2030;

DATE<em>TRUNC is especially valuable in analytics — it collapses timestamps to the start of a period (day, week, month, quarter, year), enabling time-bucket aggregations without manual arithmetic.

Conditional Expressions: CASE

CASE is SQL's general-purpose conditional. It has two forms:

SQL
-- Searched CASE (most flexible)
SELECT name, price_usd,
    CASE
        WHEN price_usd < 20   THEN 'budget'
        WHEN price_usd < 100  THEN 'mid-range'
        ELSE                       'premium'
    END AS price_tier
FROM products;

-- Simple CASE (equality checks only)
SELECT name, category,
    CASE category
        WHEN 'Electronics' THEN 'Tech'
        WHEN 'Furniture'   THEN 'Office'
        ELSE                    'Other'
    END AS category_group
FROM products;

Aggregate Functions

Aggregate functions collapse multiple rows into a single value. The core five are COUNT, SUM, AVG, MIN, and MAX. All of them ignore NULLs except COUNT(<em>).

SQL
SELECT
    COUNT(*)                    AS total_products,
    COUNT(DISTINCT category)    AS num_categories,
    SUM(price_usd)              AS total_list_value,
    AVG(price_usd)              AS avg_price,
    MIN(price_usd)              AS cheapest,
    MAX(price_usd)              AS most_expensive
FROM products;

GROUP BY: Per-Group Aggregation

GROUP BY partitions rows into groups and applies the aggregate function within each group independently. Every column in the SELECT list must either appear in GROUP BY or be wrapped in an aggregate function:

SQL
SELECT
    category,
    COUNT(*)          AS num_products,
    ROUND(AVG(price_usd), 2) AS avg_price,
    MIN(price_usd)    AS min_price,
    MAX(price_usd)    AS max_price
FROM products
GROUP BY category
ORDER BY avg_price DESC;

  category   | num_products | avg_price | min_price | max_price
-------------+--------------+-----------+-----------+-----------
 Furniture   |            2 |    424.00 |    349.00 |    499.00
 Electronics |            3 |     56.64 |     29.99 |     89.95
 Stationery  |            2 |      6.24 |      4.99 |      7.49

HAVING: Filtering on Aggregated Values

WHERE cannot reference aggregate functions because it is evaluated before aggregation. HAVING filters on the aggregated result:

SQL
-- Only categories with more than 1 product AND avg price > $30
SELECT
    category,
    COUNT(*)                    AS num_products,
    ROUND(AVG(price_usd), 2)    AS avg_price
FROM products
GROUP BY category
HAVING COUNT(*) > 1
   AND AVG(price_usd) > 30
ORDER BY avg_price DESC;

Rule of thumb: WHERE filters rows before grouping; HAVING filters groups after aggregation. For best performance, push as much filtering as possible into WHERE so fewer rows enter the aggregation step.

FILTER Clause (PostgreSQL Extension)

PostgreSQL supports a FILTER (WHERE ...) modifier on aggregate functions, enabling multiple conditional aggregates in one pass over the table — more efficient than multiple subqueries:

```sql SELECT category, COUNT() AS total, COUNT(*) FILTER (WHERE priceusd < 50) AS budgetcount, ROUND(AVG(priceusd) FILTER (WHERE instock), 2) AS avginstockprice FROM products GROUP BY category;

Code Examples

Time-Bucketed Sales Report Using DATE_TRUNC

Use DATE_TRUNC with GROUP BY to build a monthly revenue rollup — a foundational analytical query pattern.

SQL
-- Simulate an orders table with timestamps
CREATE TEMP TABLE daily_orders AS
SELECT
    order_id,
    (CURRENT_DATE - (random() * 90)::INTEGER)::DATE AS order_date,
    (random() * 200 + 10)::NUMERIC(10,2)            AS amount_usd
FROM generate_series(1, 200) AS gs(order_id);

-- Monthly revenue summary
SELECT
    DATE_TRUNC('month', order_date)     AS month,
    COUNT(*)                            AS num_orders,
    ROUND(SUM(amount_usd), 2)           AS revenue,
    ROUND(AVG(amount_usd), 2)           AS avg_order_value
FROM daily_orders
GROUP BY DATE_TRUNC('month', order_date)
ORDER BY month;
Output
         month          | num_orders | revenue   | avg_order_value
------------------------+------------+-----------+-----------------
 2026-03-01 00:00:00+00 |         66 | 7142.38   |          108.22
 2026-04-01 00:00:00+00 |         68 | 7389.01   |          108.66
 2026-05-01 00:00:00+00 |         66 | 7019.44   |          106.35

Conditional Aggregation with FILTER

Use the FILTER clause to compute multiple conditional aggregates in a single GROUP BY pass without subqueries.

SQL
SELECT
    category,
    COUNT(*)                                                    AS total_products,
    SUM(price_usd)                                              AS total_value,
    SUM(price_usd) FILTER (WHERE price_usd >= 100)              AS premium_value,
    ROUND(
        100.0 * COUNT(*) FILTER (WHERE price_usd >= 100)
        / NULLIF(COUNT(*), 0),
    1)                                                          AS pct_premium
FROM products
GROUP BY category
ORDER BY total_value DESC;
Output
  category   | total_products | total_value | premium_value | pct_premium
-------------+----------------+-------------+---------------+-------------
 Furniture   |              2 |      848.00 |        848.00 |       100.0
 Electronics |              3 |      169.93 |         89.95 |        33.3
 Stationery  |              2 |       12.48 |               |         0.0

Load Aggregated SQL Results and Plot with Matplotlib

Pull aggregated SQL results into pandas and create a horizontal bar chart — the typical handoff from SQL to Python visualization.

PYTHON
import pandas as pd
import psycopg2
import matplotlib.pyplot as plt

conn = psycopg2.connect(
    host="localhost", dbname="textbook",
    user="postgres", password="secret"
)

df = pd.read_sql_query("""
    SELECT
        category,
        ROUND(AVG(price_usd), 2) AS avg_price,
        COUNT(*)                 AS num_products
    FROM products
    GROUP BY category
    ORDER BY avg_price DESC;
""", conn)
conn.close()

fig, ax = plt.subplots()
ax.barh(df['category'], df['avg_price'], color='steelblue')
ax.set_xlabel('Average Price (USD)')
ax.set_title('Average Product Price by Category')
plt.tight_layout()
plt.savefig('avg_price_by_category.png', dpi=150)
print(df)

41.5 DDL and DML: Creating, Modifying, and Populating Tables Intermediate

Data Definition Language (DDL)

DDL statements define and modify the structure of database objects. They include CREATE, ALTER, DROP, and TRUNCATE. Unlike DML statements, DDL changes in PostgreSQL are auto-committed — there is no implicit transaction wrapper you can roll back, so be careful.

CREATE TABLE

SQL
CREATE TABLE employees (
    employee_id  SERIAL         PRIMARY KEY,
    first_name   TEXT           NOT NULL,
    last_name    TEXT           NOT NULL,
    email        TEXT           NOT NULL UNIQUE,
    hire_date    DATE           NOT NULL DEFAULT CURRENT_DATE,
    salary_usd   NUMERIC(12, 2) NOT NULL CHECK (salary_usd > 0),
    department   TEXT,
    manager_id   INTEGER        REFERENCES employees(employee_id)  -- self-referential FK
);

Key column-level constraints:

  • NOT NULL — rejects NULL values at insert/update time.
  • UNIQUE — enforces uniqueness; unlike PRIMARY KEY, it allows NULLs (multiple NULLs are permitted in PostgreSQL because NULL != NULL).
  • CHECK (expr) — the row is rejected if expr evaluates to FALSE. Note: CHECK does not reject NULLs — a NULL salary passes CHECK (salary<em>usd &gt; 0) because NULL > 0 is UNKNOWN, not FALSE.
  • DEFAULT expr — the value used when a column is omitted from an INSERT.
  • SERIAL — shorthand for an integer column with a sequence-backed default (equivalent to INTEGER GENERATED ALWAYS AS IDENTITY in modern SQL).
  • ALTER TABLE

ALTER TABLE modifies an existing table. Common operations:

SQL
-- Add a column
ALTER TABLE employees
    ADD COLUMN termination_date DATE;

-- Set a default on an existing column
ALTER TABLE employees
    ALTER COLUMN department SET DEFAULT 'General';

-- Add a table-level check constraint
ALTER TABLE employees
    ADD CONSTRAINT chk_termination
    CHECK (termination_date IS NULL OR termination_date > hire_date);

-- Rename a column (non-destructive)
ALTER TABLE employees
    RENAME COLUMN last_name TO surname;

-- Drop a column (destructive — data is lost)
ALTER TABLE employees
    DROP COLUMN termination_date;

Adding a column with NOT NULL and no default to a large table rewrites the entire table in older PostgreSQL versions. In PG 11+, adding a column with a non-volatile default is instant because the default is stored in the catalog, not backfilled row by row.

DROP TABLE

SQL
-- Drop if it exists (idempotent)
DROP TABLE IF EXISTS employees CASCADE;

CASCADE drops all objects that depend on the table (foreign keys referencing it, views built on it). Without CASCADE, PostgreSQL refuses to drop a table that other objects depend on, which is the safer default.

TRUNCATE table</em>name removes all rows from a table very fast (it does not scan rows) but, unlike DELETE, cannot be filtered. Use it to reset a table during testing.

Data Manipulation Language (DML)

DML modifies the data inside tables. DML statements run within transactions and can be rolled back.

INSERT

SQL
-- Single row
INSERT INTO employees (first_name, last_name, email, salary_usd, department)
VALUES ('Ada', 'Lovelace', 'ada@example.com', 95000.00, 'Engineering');

-- Multiple rows in one statement (much faster than one-by-one)
INSERT INTO employees (first_name, last_name, email, salary_usd, department)
VALUES
    ('Grace', 'Hopper',  'grace@example.com', 105000.00, 'Engineering'),
    ('Alan',  'Turing',  'alan@example.com',  115000.00, 'Research'),
    ('John',  'McCarthy','jmc@example.com',    98000.00, 'Research');

-- Insert from a query result
INSERT INTO employees_archive
SELECT * FROM employees WHERE hire_date < '2020-01-01';

INSERT ... ON CONFLICT is PostgreSQL's upsert mechanism:

SQL
INSERT INTO employees (email, first_name, last_name, salary_usd)
VALUES ('ada@example.com', 'Ada', 'Lovelace', 100000.00)
ON CONFLICT (email) DO UPDATE
    SET salary_usd = EXCLUDED.salary_usd;

EXCLUDED refers to the row that was rejected due to conflict. This pattern — insert if new, update if exists — is extremely common in ETL pipelines.

UPDATE

SQL
-- Give all Engineering employees a 10% raise
UPDATE employees
SET salary_usd = salary_usd * 1.10
WHERE department = 'Engineering';

-- Update multiple columns at once
UPDATE employees
SET
    department   = 'Applied Research',
    salary_usd   = 120000.00
WHERE email = 'alan@example.com';

Always include a WHERE clause in UPDATE. An UPDATE without WHERE modifies every row in the table. Most teams run SELECT ... WHERE &lt;condition&gt; to preview the affected rows before executing the UPDATE.

DELETE

SQL
-- Delete specific rows
DELETE FROM employees WHERE department = 'General';

-- Delete with a RETURNING clause (get the deleted rows back)
DELETE FROM employees
WHERE hire_date < CURRENT_DATE - INTERVAL '10 years'
RETURNING employee_id, email;

RETURNING is a PostgreSQL extension that makes a DML statement return the affected rows, saving a separate SELECT query. It works with INSERT, UPDATE, and DELETE.

Transactions

Wrap multi-statement operations in an explicit transaction to guarantee atomicity:

SQL
BEGIN;

UPDATE employees SET salary_usd = salary_usd * 1.05
WHERE department = 'Research';

INSERT INTO salary_audit (employee_id, changed_at, delta_pct)
SELECT employee_id, NOW(), 5.0
FROM employees WHERE department = 'Research';

COMMIT;  -- or ROLLBACK to undo both statements

PostgreSQL wraps every single statement in an implicit transaction, so a failed statement does not partially commit. Explicit BEGIN ... COMMIT blocks are necessary when you need multiple statements to succeed or fail together.

Code Examples

Upsert Pattern with ON CONFLICT

Implement the upsert (insert-or-update) pattern using ON CONFLICT DO UPDATE — essential for idempotent ETL pipelines.

SQL
CREATE TABLE product_inventory (
    sku         TEXT    PRIMARY KEY,
    quantity    INTEGER NOT NULL DEFAULT 0,
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Initial load
INSERT INTO product_inventory (sku, quantity) VALUES
    ('SKU-001', 100),
    ('SKU-002', 250);

-- Upsert: insert new SKUs, update existing ones
INSERT INTO product_inventory (sku, quantity, updated_at)
VALUES
    ('SKU-001', 80,  NOW()),  -- exists: update quantity
    ('SKU-003', 40,  NOW())   -- new: insert
ON CONFLICT (sku) DO UPDATE
    SET
        quantity   = EXCLUDED.quantity,
        updated_at = EXCLUDED.updated_at;

SELECT * FROM product_inventory ORDER BY sku;
Output
   sku    | quantity |          updated_at
----------+----------+-------------------------------
 SKU-001  |       80 | 2026-06-04 12:00:00.000000+00
 SKU-002  |      250 | 2026-06-04 11:55:00.000000+00
 SKU-003  |       40 | 2026-06-04 12:00:00.000000+00

Safe UPDATE Pattern with a Preview SELECT

Demonstrate the safe UPDATE workflow: preview with SELECT, then execute with RETURNING to confirm the actual changes.

SQL
-- Step 1: Preview which rows will be affected (run this first)
SELECT employee_id, first_name, surname, salary_usd
FROM employees
WHERE department = 'Engineering';

-- Step 2: Only after confirming, run the UPDATE
UPDATE employees
SET salary_usd = salary_usd * 1.10
WHERE department = 'Engineering'
RETURNING employee_id, first_name, salary_usd AS new_salary;

Batch Insert Using psycopg2 executemany

Use psycopg2 executemany with parameterized queries for a safe, efficient batch insert — never use string formatting for SQL parameters.

PYTHON
import psycopg2

conn = psycopg2.connect(
    host="localhost", dbname="textbook",
    user="postgres", password="secret"
)
conn.autocommit = False
cur = conn.cursor()

employees = [
    ('Ada',   'Lovelace', 'ada@example.com',   95000.00, 'Engineering'),
    ('Grace', 'Hopper',   'grace@example.com', 105000.00, 'Engineering'),
    ('Alan',  'Turing',   'alan@example.com',  115000.00, 'Research'),
]

insert_sql = """
    INSERT INTO employees (first_name, last_name, email, salary_usd, department)
    VALUES (%s, %s, %s, %s, %s)
    ON CONFLICT (email) DO NOTHING;
"""

try:
    cur.executemany(insert_sql, employees)
    conn.commit()
    print(f"Inserted {cur.rowcount} rows")
except psycopg2.Error as e:
    conn.rollback()
    print(f"Error: {e}")
finally:
    cur.close()
    conn.close()
Output
Inserted 3 rows