Pivot and Unpivot
The PIVOT and UNPIVOT operators (introduced in Oracle 11g) reshape data between "long" and "wide" formats — converting rows into columns and back.
This is the most common request in any reporting or analytics task: turn category-level rows into one row per entity with one column per category. Without PIVOT, you would write tedious CASE expressions for every category; with PIVOT, a one-liner.
When to Use PIVOT
Suppose you have salary data by department and year:
salary_history:
| department_id | year | total_salary |
|---|---|---|
| 10 | 2022 | 50000 |
| 10 | 2023 | 55000 |
| 10 | 2024 | 60000 |
| 20 | 2022 | 80000 |
| 20 | 2023 | 85000 |
| 20 | 2024 | 92000 |
A report consumer wants to see one row per department with columns for each year:
| department_id | 2022 | 2023 | 2024 |
|---|---|---|---|
| 10 | 50000 | 55000 | 60000 |
| 20 | 80000 | 85000 | 92000 |
That transformation is a pivot. The reverse — turning column-based data back into row form — is an unpivot.
PIVOT Anatomy
SELECT *
FROM salary_history
PIVOT (
SUM(total_salary)
FOR year IN (2022, 2023, 2024)
);
Result:
| department_id | 2022 | 2023 | 2024 |
|---|---|---|---|
| 10 | 50000 | 55000 | 60000 |
| 20 | 80000 | 85000 | 92000 |
Three things define every PIVOT:
- The aggregate function —
SUM(total_salary). PIVOT must aggregate because multiple rows can map to one cell. - The pivot column —
FOR year. Which column's values become new column headers. - The pivot values —
IN (2022, 2023, 2024). Which discrete values to turn into columns.
Why You Must Aggregate
PIVOT works at the intersection of (output row, output column). Multiple input rows can share an intersection, so PIVOT requires an aggregate to collapse them. Even when each intersection has exactly one input row, you still need an aggregate — SUM, MAX, MIN, etc. all return that single value unchanged.
SELECT *
FROM salary_history
PIVOT (
MAX(total_salary) -- works the same; one row per intersection
FOR year IN (2022, 2023, 2024)
);
What Happens to "Other" Columns?
Every column from the source query that is not named in either the aggregate or the FOR clause becomes a grouping column in the output.
Compare these two queries:
-- 1. department_id is the only "other" column → group by department
SELECT * FROM salary_history
PIVOT (SUM(total_salary) FOR year IN (2022, 2023, 2024));
-- 2 rows: one per department
-- 2. Add region to the source → region also becomes a grouping column
SELECT * FROM (
SELECT department_id, region, year, total_salary FROM salary_history
)
PIVOT (SUM(total_salary) FOR year IN (2022, 2023, 2024));
-- One row per (department, region) combination
This is the most common PIVOT surprise: an unintended grouping column inflates the row count.
-- Best practice: explicit column projection before PIVOT
SELECT *
FROM (
SELECT department_id, year, total_salary
FROM salary_history
)
PIVOT (
SUM(total_salary)
FOR year IN (2022, 2023, 2024)
);
Aliasing Pivot Columns
The generated column names default to the pivot values (2022, 2023, 2024). Aliases let you produce friendlier names:
SELECT *
FROM salary_history
PIVOT (
SUM(total_salary)
FOR year IN (
2022 AS y2022,
2023 AS y2023,
2024 AS y2024
)
);
Result:
| department_id | y2022 | y2023 | y2024 |
|---|---|---|---|
| 10 | 50000 | 55000 | 60000 |
| 20 | 80000 | 85000 | 92000 |
You may also alias the aggregate:
PIVOT (
SUM(total_salary) AS total
FOR year IN (2022 AS y22, 2023 AS y23, 2024 AS y24)
)
This produces columns like Y22_TOTAL, Y23_TOTAL, Y24_TOTAL — useful when there are multiple aggregates (see next).
Multiple Aggregates
PIVOT can compute several aggregates at once. The resulting columns are named <value>_<aggregate-alias>.
SELECT *
FROM salary_history
PIVOT (
SUM(total_salary) AS total,
COUNT(*) AS n
FOR year IN (2023, 2024)
);
Result:
| department_id | 2023_TOTAL | 2023_N | 2024_TOTAL | 2024_N |
|---|---|---|---|---|
| 10 | 55000 | 1 | 60000 | 1 |
| 20 | 85000 | 1 | 92000 | 1 |
Useful for side-by-side metrics: "total revenue and number of orders per region".
Pivoting on Multiple Columns
You can pivot on a tuple of columns by listing them in FOR (...) IN ((...)):
sales:
| region | quarter | amount |
|---|---|---|
| E | Q1 | 100 |
| E | Q2 | 200 |
| W | Q1 | 300 |
| W | Q2 | 400 |
SELECT *
FROM sales
PIVOT (
SUM(amount)
FOR (region, quarter) IN (
('E', 'Q1') AS east_q1,
('E', 'Q2') AS east_q2,
('W', 'Q1') AS west_q1,
('W', 'Q2') AS west_q2
)
);
Result:
| east_q1 | east_q2 | west_q1 | west_q2 |
|---|---|---|---|
| 100 | 200 | 300 | 400 |
Each tuple becomes its own column.
UNPIVOT — Columns Back to Rows
UNPIVOT reverses the operation. Given the wide-format salary_by_year:
salary_by_year:
| department_id | y2022 | y2023 | y2024 |
|---|---|---|---|
| 10 | 50000 | 55000 | 60000 |
| 20 | 80000 | 85000 | 92000 |
SELECT *
FROM salary_by_year
UNPIVOT (
total_salary
FOR year IN (y2022 AS 2022, y2023 AS 2023, y2024 AS 2024)
);
Result:
| department_id | year | total_salary |
|---|---|---|
| 10 | 2022 | 50000 |
| 10 | 2023 | 55000 |
| 10 | 2024 | 60000 |
| 20 | 2022 | 80000 |
| 20 | 2023 | 85000 |
| 20 | 2024 | 92000 |
The clauses mean:
total_salary— the output column that will hold the cell valuesFOR year— the output column that will hold the source column namesIN (y2022 AS 2022, ...)— list which source columns to unpivot, with optional aliases for the new value
EXCLUDE vs INCLUDE NULLS
By default, UNPIVOT skips rows where every source column is NULL. To keep them:
... UNPIVOT INCLUDE NULLS (
total_salary FOR year IN (y2022, y2023, y2024)
)
| Behaviour | Default | Override |
|---|---|---|
| Skip NULLs | UNPIVOT (= UNPIVOT EXCLUDE NULLS) |
n/a |
| Keep NULLs | n/a | UNPIVOT INCLUDE NULLS |
For most reporting tasks, the default is correct. Use INCLUDE NULLS when you specifically need to record that "no data was reported for Q3" as a row rather than as missing data.
Dynamic Pivot — When Values Aren't Known in Advance
PIVOT requires the value list to be hard-coded. If you don't know the years until query time, plain PIVOT fails. Two workarounds exist:
Option 1 — Generate the SQL Dynamically
DECLARE
v_sql VARCHAR2(4000);
v_in VARCHAR2(4000);
BEGIN
SELECT LISTAGG('''' || year_str || ''' AS y' || year_str, ', ')
WITHIN GROUP (ORDER BY year_str)
INTO v_in
FROM (SELECT DISTINCT TO_CHAR(year) AS year_str FROM salary_history);
v_sql := 'SELECT * FROM salary_history
PIVOT (SUM(total_salary) FOR year IN (' || v_in || '))';
EXECUTE IMMEDIATE v_sql; -- or open a refcursor with it
END;
/
This approach builds a string and executes it dynamically.
Option 2 — XML-based Dynamic Pivot
Oracle supports an XML keyword that produces an XMLType column with all categories included automatically:
SELECT *
FROM salary_history
PIVOT XML (
SUM(total_salary)
FOR year IN (ANY)
);
Result:
| department_id | year_xml |
|---|---|
| 10 | <PivotSet>...<column name="2022">50000</column>...</PivotSet> |
The result is a single XML column per row containing all the pivot values. You then parse it with XMLTABLE. This is awkward to consume but useful when the value list truly is unknown.
In practice, most reports either know the value set in advance (months of the year, fiscal quarters, product categories) or build the SQL dynamically in their application layer.
PIVOT vs CASE Expressions — The Old Way
Before PIVOT existed, this was the only way to pivot in SQL:
SELECT department_id,
SUM(CASE WHEN year = 2022 THEN total_salary END) AS y2022,
SUM(CASE WHEN year = 2023 THEN total_salary END) AS y2023,
SUM(CASE WHEN year = 2024 THEN total_salary END) AS y2024
FROM salary_history
GROUP BY department_id;
This still works and is the only option on databases without PIVOT. On Oracle, PIVOT is preferred because it's:
- More concise
- Less repetitive
- Easier to read for non-trivial value sets
- Self-documenting (the FOR ... IN list shows the pivot key clearly)
However, CASE is more flexible in two ways:
- Each column can use a different aggregate (PIVOT only allows one aggregate per pivot expression)
- Each column can use different filter logic (range comparisons, multi-column predicates, conditional NULLs)
-- CASE handles this; PIVOT cannot:
SELECT department_id,
AVG(CASE WHEN year = 2024 THEN total_salary END) AS avg_24,
SUM(CASE WHEN year >= 2023 THEN total_salary END) AS recent_sum,
COUNT(DISTINCT CASE WHEN year < 2023 THEN employee_id END) AS legacy_emps
FROM salary_history
GROUP BY department_id;
Worked Example — Pivot for a Quarterly Report
You need a manager-facing report: each department's salary spend per quarter for the past year.
Source (raw_payments):
| dept_id | pay_date | amount |
|---|---|---|
| 10 | 2024-01-15 | 5000 |
| 10 | 2024-04-15 | 5200 |
| 10 | 2024-07-15 | 5400 |
| 10 | 2024-10-15 | 5600 |
| 20 | 2024-01-15 | 9000 |
| 20 | 2024-04-15 | 9100 |
| 20 | 2024-07-15 | 9200 |
| 20 | 2024-10-15 | 9300 |
Query:
SELECT *
FROM (
SELECT dept_id,
'Q' || TO_CHAR(pay_date, 'Q') AS qtr,
amount
FROM raw_payments
WHERE pay_date BETWEEN DATE '2024-01-01' AND DATE '2024-12-31'
)
PIVOT (
SUM(amount) AS total
FOR qtr IN ('Q1' AS q1, 'Q2' AS q2, 'Q3' AS q3, 'Q4' AS q4)
)
ORDER BY dept_id;
Result:
| dept_id | Q1_TOTAL | Q2_TOTAL | Q3_TOTAL | Q4_TOTAL |
|---|---|---|---|---|
| 10 | 5000 | 5200 | 5400 | 5600 |
| 20 | 9000 | 9100 | 9200 | 9300 |
Three things to notice:
- The inline view derives the
qtrcolumn frompay_date. - Filtering happens before PIVOT — only 2024 rows enter the pivot.
- The IN list aliases produce clean column names.
Worked Example — Unpivot Survey Responses
Marketing has a wide survey table with one column per question:
survey:
| user_id | q1_rating | q2_rating | q3_rating |
|---|---|---|---|
| 1 | 5 | 4 | 3 |
| 2 | 3 | 5 | 4 |
You need to feed an analytics tool that expects one row per (user, question, rating):
SELECT user_id, question, rating
FROM survey
UNPIVOT (
rating
FOR question IN (q1_rating AS 'Q1', q2_rating AS 'Q2', q3_rating AS 'Q3')
);
Result:
| user_id | question | rating |
|---|---|---|
| 1 | Q1 | 5 |
| 1 | Q2 | 4 |
| 1 | Q3 | 3 |
| 2 | Q1 | 3 |
| 2 | Q2 | 5 |
| 2 | Q3 | 4 |
Long-format data is much easier to aggregate, filter, and feed into reporting tools.
Common Errors
| Error | Cause | Fix |
|---|---|---|
| ORA-56901: non-constant expression is not allowed for pivot/unpivot values | Tried to use a subquery or bind variable in IN list | Use dynamic SQL or PIVOT XML for unknown values |
| ORA-00918: column ambiguously defined | Pivot output column name collides with another column | Add aliases in the IN clause |
| ORA-01790: expression must have same datatype as corresponding expression (UNPIVOT) | Source columns being unpivoted have different data types | Cast all source columns to the same type before UNPIVOT |
| Too many output rows | Forgot to project columns; PIVOT used every source column as a grouping key | Wrap the source in an inline view with only the columns you want |
| Output values are NULL | Pivot value list missed a category that exists in data | Add it to the IN list |
| ORA-00904: invalid identifier on pivot column | Misspelled the pivot column name in FOR clause |
Verify the column exists in the inline view |
Interview Corner
▶ Show answer
The most common cause: an unintended grouping column.
PIVOT treats every column in its input that is not named in the aggregate or the FOR clause as a grouping column. If your source is SELECT * FROM employees, every column of employees becomes part of the implicit grouping — most of which are unique per row, so the grouping never collapses.
The fix: wrap the source in an inline view that selects only the columns you want as groupers, plus the pivot column and the aggregate column.
-- BAD: department_id, manager_id, hire_date, ... all become grouping keys
SELECT * FROM employees
PIVOT (SUM(salary) FOR department_id IN (10, 20, 30));
-- GOOD: only job_id is a grouper
SELECT *
FROM (SELECT job_id, department_id, salary FROM employees)
PIVOT (SUM(salary) FOR department_id IN (10, 20, 30));
Result rule of thumb: the output has one row per distinct combination of grouping columns. Choose grouping columns deliberately.
▶ Show answer
PIVOT is concise but restrictive. Use CASE when you need any of these:
Different aggregates per column — PIVOT applies one aggregate to all columns; CASE lets each be different:
SELECT AVG(CASE WHEN region = 'E' THEN sales END) AS east_avg, MAX(CASE WHEN region = 'W' THEN sales END) AS west_max FROM orders;Range / inequality predicates — PIVOT only matches equality:
SUM(CASE WHEN amount BETWEEN 0 AND 100 THEN amount END) AS small, SUM(CASE WHEN amount > 100 THEN amount END) AS largeMulti-column conditions — PIVOT only pivots on listed values:
SUM(CASE WHEN region = 'US' AND product = 'A' THEN amount END)Conditional inclusion — exclude rows that match certain logic:
SUM(CASE WHEN status != 'cancelled' THEN amount END)Cross-database portability — CASE works everywhere; PIVOT is Oracle/SQL Server specific.
When PIVOT wins: identical aggregate over many equality-based categories. The IN list is shorter than N copies of CASE.
▶ Show answer
UNPIVOT is exactly built for this. Given:
revenue_by_month:
| customer_id | jan | feb | mar | apr | may | jun |
|---|---|---|---|---|---|---|
| 1 | 100 | 110 | 105 | 120 | 130 | 125 |
SELECT customer_id, month_name, revenue
FROM revenue_by_month
UNPIVOT (
revenue
FOR month_name IN (
jan AS 'Jan', feb AS 'Feb', mar AS 'Mar',
apr AS 'Apr', may AS 'May', jun AS 'Jun'
)
);
Result:
| customer_id | month_name | revenue |
|---|---|---|
| 1 | Jan | 100 |
| 1 | Feb | 110 |
| 1 | Mar | 105 |
| 1 | Apr | 120 |
| 1 | May | 130 |
| 1 | Jun | 125 |
If you want a real date column instead of a string month name, parse the alias in an outer query:
SELECT customer_id,
TO_DATE(month_name || '-2024', 'Mon-YYYY') AS month_start,
revenue
FROM ( /* the UNPIVOT above */ );
By default, UNPIVOT drops NULL cells — months a customer had no revenue won't appear. Use UNPIVOT INCLUDE NULLS to keep them as zero-revenue records.
Related Topics
- Aggregate Functions — PIVOT is essentially
GROUP BYwith cross-tabulation - Subqueries — wrap your source in an inline view to control PIVOT's grouping columns
- Window Functions — compute year-over-year or running totals across pivoted columns
- CTEs & Procedures — extract pivot input into a CTE for readability
- Performance — PIVOT can produce large intermediate sort sets; consider materialising the input