Pivot in PostgreSQL
Introduction
Pivot (or Pivot Table / Crosstab) is a technique used to transform data from rows to columns. This technique is very popular in data analysis and reporting, helping group scattered data rows into an easy-to-read summary table.
Example of initial data (Long format):
| Year | Quarter | Revenue |
| :--- | :------ | :------ |
| 2023 | Q1 | 100 |
| 2023 | Q2 | 150 |
| 2024 | Q1 | 120 |
| 2024 | Q2 | 180 |
Data after Pivot (Wide format):
| Year | Q1 | Q2 |
| :--- | :--- | :--- |
| 2023 | 100 | 150 |
| 2024 | 120 | 180 |
How to Implement
Unlike SQL Server or Oracle (which have native PIVOT syntax), PostgreSQL does not have a direct PIVOT keyword. You can accomplish this using the following common approaches:
- Using conditional aggregate functions (
SUM + FILTERorCASE) is the simplest method, requiring no additional extensions and optimizing performance well. - Using the
tablefuncextension and thecrosstab()function.
Use Cases
- Generating sales reports: Summarizing by month, quarter and year.
- Matrix statistics: Viewing student scores by subject or click counts by marketing campaign.
- Data preparation: Transforming raw data for rendering charts or exporting to Excel or CSV files.
Detail
Create the tables as follows:
CREATE TABLE orders (
order_id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
order_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status VARCHAR(20) NOT NULL CHECK (status IN ('PENDING', 'COMPLETED', 'CANCELLED'))
);
CREATE TABLE order_items (
item_id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(order_id) ON DELETE CASCADE,
product_category VARCHAR(50) NOT NULL,
amount NUMERIC(12, 2) NOT NULL CHECK (amount >= 0)
);
After seeding the data, use the following queries to calculate category revenue statistics by month in the year:
-- Query 1
SELECT
oi.product_category AS "Category",
COALESCE(SUM(oi.amount) FILTER (WHERE EXTRACT(MONTH FROM o.order_date) = 1), 0) AS "January",
COALESCE(SUM(oi.amount) FILTER (WHERE EXTRACT(MONTH FROM o.order_date) = 2), 0) AS "February",
COALESCE(SUM(oi.amount) FILTER (WHERE EXTRACT(MONTH FROM o.order_date) = 3), 0) AS "March",
SUM(oi.amount) AS "Total Revenue"
FROM order_items oi
JOIN orders o ON oi.order_id = o.order_id
WHERE o.status = 'COMPLETED'
AND o.order_date >= '2026-01-01' AND o.order_date < '2027-01-01'
GROUP BY oi.product_category
ORDER BY "Total Revenue" DESC;
-- Query 2
CREATE EXTENSION IF NOT EXISTS tablefunc;
SELECT * FROM crosstab(
$$
SELECT
oi.product_category,
EXTRACT(MONTH FROM o.order_date) AS month_num,
SUM(oi.amount) AS total_amount
FROM order_items oi
JOIN orders o ON oi.order_id = o.order_id
WHERE o.status = 'COMPLETED'
AND o.order_date >= '2026-01-01' AND o.order_date < '2027-01-01'
GROUP BY oi.product_category, EXTRACT(MONTH FROM o.order_date)
ORDER BY 1, 2
$$,
$$ VALUES (1), (2), (3) $$
) AS ct (
product_category VARCHAR(50),
january NUMERIC(12,2),
february NUMERIC(12,2),
march NUMERIC(12,2)
);
Query 1: This approach uses aggregate functions such as COALESCE, SUM and FILTER, where each column represents a month and each row represents a category.
Query 2: This approach uses crosstab to pivot the data.
- You need to enable the
tablefuncextension to use thecrosstabfunction. - First, retrieve
product_category,monthandtotal_amount, whereORDER BY 1, 2sorts byproduct_categoryandmonth. - How
crosstabworks requires the query to always return 3 columns, corresponding toproduct_category,monthandtotal_amount:- Column 1 (Row ID): The column kept intact as a label, where each unique value forms a row.
- Column 2 (Category): The column whose values are evaluated against
$$VALUES (1), (2), (3)$$. - Column 3 (Value): The actual value filled into the cells.
- Therefore, when using
$$VALUES (1), (2), (3)$$, it evaluates the value of themonthcolumn:- If it is 1, it places the value of column 3 (
total_amount) into thejanuarycolumn of the output. - If it is 2, it places the value of column 3 (
total_amount) into thefebruarycolumn of the output. - If it is 3, it places the value of column 3 (
total_amount) into themarchcolumn of the output.
- If it is 1, it places the value of column 3 (
- The final part aliases the columns to display.
- Since using
crosstabis quite complex, in most cases you should prioritize usingFILTERfirst and only considercrosstabif the number of columns to extract is exceptionally large.
Here is how it works when using a MATERIALIZED VIEW:
CREATE MATERIALIZED VIEW mv_monthly_sales_by_category AS
SELECT
oi.product_category,
EXTRACT(YEAR FROM o.order_date) AS order_year,
EXTRACT(MONTH FROM o.order_date) AS order_month,
SUM(oi.amount) AS total_revenue
FROM order_items oi
JOIN orders o ON oi.order_id = o.order_id
WHERE o.status = 'COMPLETED'
GROUP BY
oi.product_category,
EXTRACT(YEAR FROM o.order_date),
EXTRACT(MONTH FROM o.order_date);
CREATE UNIQUE INDEX idx_mv_monthly_sales
ON mv_monthly_sales_by_category (product_category, order_year, order_month);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_monthly_sales_by_category;
-- Query 1
SELECT
product_category AS "Category",
COALESCE(SUM(total_revenue) FILTER (WHERE order_month = 1), 0) AS "January",
COALESCE(SUM(total_revenue) FILTER (WHERE order_month = 2), 0) AS "February",
COALESCE(SUM(total_revenue) FILTER (WHERE order_month = 3), 0) AS "March",
SUM(total_revenue) AS "Total Revenue"
FROM mv_monthly_sales_by_category
WHERE order_year = 2026
GROUP BY product_category
ORDER BY "Total Revenue" DESC;
-- Query 2
SELECT
product_category AS "Category",
COALESCE(SUM(total_revenue) FILTER (WHERE order_year = 2024), 0) AS "2024",
COALESCE(SUM(total_revenue) FILTER (WHERE order_year = 2025), 0) AS "2025",
COALESCE(SUM(total_revenue) FILTER (WHERE order_year = 2026), 0) AS "2026",
SUM(total_revenue) AS "Total All Time"
FROM mv_monthly_sales_by_category
WHERE order_year IN (2024, 2025, 2026)
GROUP BY product_category
ORDER BY "Total All Time" DESC;
- Use a
MATERIALIZED VIEWto precalculate and store revenue for each category by month. - Supports various report types, such as monthly or quarterly reports.
- You need to create a
UNIQUE INDEXto enableREFRESH MATERIALIZED VIEW CONCURRENTLY, along with a scheduled cron job to update data periodically.
Query 1: Used to retrieve category data by month within a specific year.
Query 2: Used to retrieve category data across multiple years.
Happy coding!
Comments
Post a Comment