SQLMentor // learn sql

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:

  1. The aggregate functionSUM(total_salary). PIVOT must aggregate because multiple rows can map to one cell.
  2. The pivot columnFOR year. Which column's values become new column headers.
  3. The pivot valuesIN (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.

Always wrap your source in an inline view that selects exactly the columns you want. This makes the grouping columns explicit and avoids accidentally grouping by every column in the base table.
-- 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 values
  • FOR year — the output column that will hold the source column names
  • IN (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:

  1. Each column can use a different aggregate (PIVOT only allows one aggregate per pivot expression)
  2. 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:

  1. The inline view derives the qtr column from pay_date.
  2. Filtering happens before PIVOT — only 2024 rows enter the pivot.
  3. 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

IQ · Pivot
A PIVOT query is returning many more rows than expected. What's the likely cause?
▶ 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.

IQ · Pivot vs CASE
When would you choose CASE expressions over PIVOT?
▶ Show answer

PIVOT is concise but restrictive. Use CASE when you need any of these:

  1. 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;
    
  2. 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 large
    
  3. Multi-column conditions — PIVOT only pivots on listed values:

    SUM(CASE WHEN region = 'US' AND product = 'A' THEN amount END)
    
  4. Conditional inclusion — exclude rows that match certain logic:

    SUM(CASE WHEN status != 'cancelled' THEN amount END)
    
  5. 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.

IQ · Unpivot
How would you go from a calendar matrix (one column per month) to a time-series table (one row per month)?
▶ 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 BY with 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