SQLMentor // learn postgresql

SELECT, Filtering & Aggregates

PostgreSQL fully supports standard SQL query syntax and adds several powerful extensions. This chapter covers SELECT through HAVING, highlighting the PostgreSQL-specific features that make it uniquely expressive.

We'll use this sample dataset:

CREATE TABLE orders (
    order_id    SERIAL PRIMARY KEY,
    customer    TEXT,
    region      TEXT,
    product     TEXT,
    quantity    INTEGER,
    unit_price  NUMERIC(10,2),
    order_date  DATE
);

INSERT INTO orders (customer, region, product, quantity, unit_price, order_date) VALUES
    ('Alice',   'North', 'Widget',  3, 19.99, '2024-01-05'),
    ('Bob',     'South', 'Gadget',  1, 49.99, '2024-01-07'),
    ('Carol',   'North', 'Widget',  5, 19.99, '2024-01-10'),
    ('Alice',   'East',  'Gadget',  2, 49.99, '2024-01-12'),
    ('Dave',    'South', 'Widget',  4, 19.99, '2024-01-15'),
    ('Carol',   'East',  'Doohick', 1, 99.99, '2024-01-18'),
    ('Bob',     'North', 'Widget',  2, 19.99, '2024-01-20'),
    ('Alice',   'North', 'Doohick', 1, 99.99, '2024-01-22');

SELECT and FROM

-- Select all columns
SELECT * FROM orders;

-- Select specific columns with expressions
SELECT
    order_id,
    customer,
    quantity * unit_price  AS total_value,
    UPPER(product)         AS product_upper
FROM orders;

WHERE — Filtering Rows

-- Basic comparison
SELECT * FROM orders WHERE region = 'North';

-- Multiple conditions
SELECT * FROM orders
WHERE region = 'North'
  AND unit_price > 20.00;

-- IN list
SELECT * FROM orders
WHERE region IN ('North', 'East');

-- BETWEEN (inclusive on both ends)
SELECT * FROM orders
WHERE order_date BETWEEN '2024-01-10' AND '2024-01-20';

-- LIKE pattern matching
SELECT * FROM orders WHERE product LIKE 'G%';    -- starts with G
SELECT * FROM orders WHERE product ILIKE 'g%';   -- case-insensitive (PostgreSQL-specific)

-- IS NULL / IS NOT NULL
SELECT * FROM orders WHERE region IS NOT NULL;
ILIKE is a PostgreSQL extension for case-insensitive pattern matching. Standard SQL uses LOWER(col) LIKE 'pattern', but ILIKE is cleaner and can use indexes when combined with the pg_trgm extension.

DISTINCT and DISTINCT ON

Standard DISTINCT removes duplicate rows across all selected columns:

-- Unique regions
SELECT DISTINCT region FROM orders ORDER BY region;
region
East
North
South

DISTINCT ON is a powerful PostgreSQL extension that keeps only one row per group of a specified column — you choose which row by using ORDER BY:

-- Get the most recent order per customer
SELECT DISTINCT ON (customer)
    customer,
    order_id,
    order_date,
    product
FROM orders
ORDER BY customer, order_date DESC;
customer order_id order_date product
Alice 8 2024-01-22 Doohick
Bob 7 2024-01-20 Widget
Carol 6 2024-01-18 Doohick
Dave 5 2024-01-15 Widget
DISTINCT ON must be the first thing after SELECT, and the column(s) in DISTINCT ON (...) must appear as the first column(s) in ORDER BY. The ORDER BY determines which row "wins" within each group.

GROUP BY and Aggregates

-- Count orders per region
SELECT
    region,
    COUNT(*)              AS order_count,
    SUM(quantity)         AS total_units,
    AVG(unit_price)       AS avg_price,
    MIN(order_date)       AS first_order,
    MAX(order_date)       AS last_order
FROM orders
GROUP BY region
ORDER BY total_units DESC;
region order_count total_units avg_price first_order last_order
North 4 11 39.99 2024-01-05 2024-01-22
South 2 5 34.99 2024-01-07 2024-01-15
East 2 3 74.99 2024-01-12 2024-01-18

ARRAY_AGG and STRING_AGG

PostgreSQL aggregates can collect values into arrays or strings:

-- Collect all products ordered per customer into an array
SELECT
    customer,
    ARRAY_AGG(product ORDER BY order_date)  AS products_ordered,
    STRING_AGG(product, ', ' ORDER BY order_date) AS products_csv
FROM orders
GROUP BY customer;
customer products_ordered products_csv
Alice {Widget,Gadget,Doohick} Widget, Gadget, Doohick
Bob {Gadget,Widget} Gadget, Widget
Carol {Widget,Doohick} Widget, Doohick
Dave {Widget} Widget

JSON_AGG

-- Aggregate rows into a JSON array
SELECT
    customer,
    JSON_AGG(JSON_BUILD_OBJECT('product', product, 'qty', quantity)) AS order_details
FROM orders
GROUP BY customer;

The FILTER Clause — PostgreSQL-Specific

The FILTER clause allows conditional aggregation without CASE expressions. It is far cleaner than the traditional workaround:

-- Traditional approach (works everywhere but verbose)
SELECT
    region,
    COUNT(CASE WHEN product = 'Widget' THEN 1 END)  AS widget_orders,
    COUNT(CASE WHEN product = 'Gadget' THEN 1 END)  AS gadget_orders
FROM orders
GROUP BY region;

-- PostgreSQL FILTER clause (clean and expressive)
SELECT
    region,
    COUNT(*) FILTER (WHERE product = 'Widget')  AS widget_orders,
    COUNT(*) FILTER (WHERE product = 'Gadget')  AS gadget_orders,
    SUM(quantity * unit_price) FILTER (WHERE unit_price > 50) AS premium_revenue
FROM orders
GROUP BY region;
region widget_orders gadget_orders premium_revenue
North 3 0 99.99
South 1 1 null
East 0 1 149.98
FILTER (WHERE ...) works with any aggregate function: SUM, AVG, MAX, COUNT, ARRAY_AGG, STRING_AGG, and even custom aggregates. The filtered rows are excluded from the aggregate computation entirely — NULLs are not counted.

HAVING — Filtering Groups

HAVING filters groups after GROUP BY (the way WHERE filters individual rows before grouping):

-- Only regions with more than 2 orders
SELECT
    region,
    COUNT(*) AS order_count
FROM orders
GROUP BY region
HAVING COUNT(*) > 2;
-- Customers who spent more than $100 total
SELECT
    customer,
    SUM(quantity * unit_price) AS total_spend
FROM orders
GROUP BY customer
HAVING SUM(quantity * unit_price) > 100
ORDER BY total_spend DESC;
customer total_spend
Alice 299.94
Carol 199.94

ROLLUP, CUBE, and GROUPING SETS

These are extensions for multi-level subtotals — very useful for reporting.

ROLLUP — Hierarchical Subtotals

-- Sales totals at each level: region+product, region, grand total
SELECT
    region,
    product,
    SUM(quantity * unit_price) AS revenue
FROM orders
GROUP BY ROLLUP (region, product)
ORDER BY region NULLS LAST, product NULLS LAST;
region product revenue
East Doohick 99.99
East Gadget 99.98
East null 199.97
North Doohick 99.99
North Widget 119.94
North null 219.93
South Gadget 49.99
South Widget 79.96
South null 129.95
null null 549.85

The NULL rows are subtotals — NULL region is the grand total, NULL product is the region subtotal.

CUBE — All Combinations

-- CUBE generates subtotals for every combination
SELECT
    region,
    product,
    SUM(quantity) AS units
FROM orders
GROUP BY CUBE (region, product);
-- Generates: (region, product), (region), (product), ()

GROUPING SETS — Custom Combinations

-- Exactly the groupings you want
SELECT
    region,
    product,
    SUM(quantity) AS units
FROM orders
GROUP BY GROUPING SETS (
    (region, product),   -- detail level
    (region),            -- region subtotal
    ()                   -- grand total
);

Use GROUPING(col) to tell NULL-subtotals apart from actual NULLs:

SELECT
    CASE GROUPING(region) WHEN 1 THEN 'ALL REGIONS' ELSE region END AS region,
    SUM(quantity) AS units
FROM orders
GROUP BY ROLLUP (region);

ORDER BY and LIMIT

-- Sort by computed column
SELECT customer, SUM(quantity * unit_price) AS spend
FROM orders
GROUP BY customer
ORDER BY spend DESC
LIMIT 3;

-- FETCH FIRST — SQL-standard alternative to LIMIT
SELECT customer, SUM(quantity * unit_price) AS spend
FROM orders
GROUP BY customer
ORDER BY spend DESC
FETCH FIRST 3 ROWS ONLY;

-- Pagination: skip first 3, show next 3
SELECT * FROM orders ORDER BY order_id LIMIT 3 OFFSET 3;
LIMIT and FETCH FIRST are functionally identical in PostgreSQL. FETCH FIRST is part of the SQL standard. Both require ORDER BY for stable, predictable results — without it, the "top N" rows are arbitrary.