Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

SQL (PostgreSQL, MySQL, SQLite)  Module 21 – SQL Server and Oracle side by side

PIVOT, APPLY and windows in SQL Server and Oracle

Turn rows into columns with PIVOT or conditional aggregation, fetch top rows per group with APPLY, and use the window frames each engine accepts.

  • Advanced
  • 30 minutes
  • Examples run with sql.js 1.14.2
  • By MySmartCoPilot
Free core, Pro depth: 11 of 30 minutes free
Free: What a PIVOT does · One matrix, three ways of writing it · UNPIVOT and the NULL cells · 2 more sections. With a pass: 2 in-depth parts and 3 interview answers, about 19 minutes.

What you will learn

  • Write pivots and unpivots with each engine's operator and with portable conditional aggregation
  • Use CROSS APPLY and OUTER APPLY for top-N-per-row lookups
  • Identify the window frames and window filters each engine supports
  • Retrieve the top row per group with TOP WITH TIES, FETCH FIRST WITH TIES and KEEP (DENSE_RANK FIRST)

Before you start

What you’ll learn · Before you start

What you will learn

  • Write pivots and unpivots with each engine's operator and with portable conditional aggregation
  • Use CROSS APPLY and OUTER APPLY for top-N-per-row lookups
  • Identify the window frames and window filters each engine supports
  • Retrieve the top row per group with TOP WITH TIES, FETCH FIRST WITH TIES and KEEP (DENSE_RANK FIRST)

Before you start

On this page

Three parts of a report query differ most between SQL Server and Oracle. PIVOT turns the values of one column into columns: both engines have the operator, but SQL Server’s takes one aggregate with the values in brackets, while Oracle’s takes several aggregates with quoted values and builds the column names from aliases. APPLY runs a subquery once per row of the table before it, which is how you fetch “the largest order of each customer”; SQL Server has CROSS APPLY and OUTER APPLY, and Oracle accepts both, as well as LATERAL. Window functions are where the two differ in the details: Oracle runs RANGE frames with offsets, GROUPS frames and QUALIFY, while the windows in SQL Server allow RANGE only up to UNBOUNDED and CURRENT ROW. Conditional aggregation, ROW_NUMBER in a CTE and a calendar table are the forms that run unchanged on both.

What a PIVOT does

Payment rows go through PIVOT with a fixed list of modes and become one row per city; a mode outside the list is left out.payments, one row per paymentPune UPI 450 · Pune UPI 1200 · Pune Card 3000Pune Cash 200 · Indore Wallet 120 · …PIVOT (SUM(amount) FOR mode IN (UPI, Card, Cash))groups by city, one column per listed modeOne row per cityPune: UPI 1650 · Card 3000 · Cash 200Kochi: UPI 700 · Card NULL · Cash 240Wallet is not in the list:its 120 appears in no columnleft out

What PIVOT does with the payments table

Text description of the diagram

The diagram shows four boxes from top to bottom.

  1. The payments table, one row per payment: for example Pune UPI 450, Pune UPI 1200, Pune Card 3000, Pune Cash 200 and Indore Wallet 120.
  2. The operator PIVOT (SUM(amount) FOR mode IN (UPI, Card, Cash)). It groups the rows by every column the PIVOT does not name, here the city, and makes one output column for each mode in the list.
  3. The result has one row per city: Pune has UPI 1650, Card 3000 and Cash 200; Kochi has UPI 700, no card payments (NULL) and Cash 240.
  4. A box to the side shows what the fixed list leaves out: Indore's Wallet payment of 120 is in no column, because Wallet is not in the IN list.

A PIVOT groups the rows by every column it does not name, then makes one output column for each value in its IN list. The list is fixed when the query is written, so a new value in the data appears in no column: a report silently loses it.

One matrix, three ways of writing it

Payments by city and mode, with one and with two aggregates per mode SQL · payment_mix.sql
-- A city by payment-mode matrix, built with conditional aggregation: the portable form of what both engines' PIVOT
-- operators compute. For comparison, the operators would read:
--   SQL Server:  SELECT city, [UPI], [Card], [Cash]
--                FROM (SELECT city, mode, amount FROM payments) AS src
--                PIVOT (SUM(amount) FOR mode IN ([UPI], [Card], [Cash])) AS p;
--   Oracle:      SELECT * FROM (SELECT city, mode, amount FROM payments)
--                PIVOT (SUM(amount) AS total, COUNT(*) AS cnt FOR mode IN ('UPI' AS upi, 'Card' AS card, 'Cash' AS cash));
-- SQL Server takes one aggregate; Oracle takes several and names the columns UPI_TOTAL, UPI_CNT and so on.
CREATE TABLE payments (id INTEGER PRIMARY KEY, city TEXT NOT NULL, mode TEXT NOT NULL, amount INTEGER NOT NULL);
INSERT INTO payments (city, mode, amount) VALUES
  ('Pune', 'UPI', 450), ('Pune', 'UPI', 1200), ('Pune', 'Card', 3000), ('Pune', 'Cash', 200),
  ('Kochi', 'UPI', 700), ('Kochi', 'Cash', 150), ('Kochi', 'Cash', 90),
  ('Indore', 'Card', 2500), ('Indore', 'UPI', 380), ('Indore', 'Wallet', 120);

-- 1. One aggregate per mode, as SQL Server's PIVOT gives it: a mode a city never used is NULL.
SELECT city,
       sum(CASE WHEN mode = 'UPI'  THEN amount END) AS upi,
       sum(CASE WHEN mode = 'Card' THEN amount END) AS card,
       sum(CASE WHEN mode = 'Cash' THEN amount END) AS cash
FROM payments
GROUP BY city
ORDER BY city;

-- 2. Two aggregates per mode, as Oracle's PIVOT can give them, with zeros instead of NULLs and a total that also
--    counts the 'Wallet' payment the fixed list of columns leaves out.
SELECT city,
       coalesce(sum(CASE WHEN mode = 'UPI' THEN amount END), 0) AS upi_total,
       count(CASE WHEN mode = 'UPI' THEN 1 END)                   AS upi_cnt,
       coalesce(sum(CASE WHEN mode = 'Card' THEN amount END), 0) AS card_total,
       count(CASE WHEN mode = 'Card' THEN 1 END)                  AS card_cnt,
       sum(amount)                                                AS all_modes,
       sum(CASE WHEN mode NOT IN ('UPI', 'Card', 'Cash') THEN amount ELSE 0 END) AS not_in_list
FROM payments
GROUP BY city
ORDER BY city;

Output

┌────────┬──────┬──────┬──────┐
│  city  │ upi  │ card │ cash │
├────────┼──────┼──────┼──────┤
│ Indore │ 380  │ 2500 │      │
│ Kochi  │ 700  │      │ 240  │
│ Pune   │ 1650 │ 3000 │ 200  │
└────────┴──────┴──────┴──────┘
┌────────┬───────────┬─────────┬────────────┬──────────┬───────────┬─────────────┐
│  city  │ upi_total │ upi_cnt │ card_total │ card_cnt │ all_modes │ not_in_list │
├────────┼───────────┼─────────┼────────────┼──────────┼───────────┼─────────────┤
│ Indore │ 380       │ 1       │ 2500       │ 1        │ 3000      │ 120         │
│ Kochi  │ 700       │ 1       │ 0          │ 0        │ 940       │ 0           │
│ Pune   │ 1650      │ 2       │ 3000       │ 1        │ 4850      │ 0           │
└────────┴───────────┴─────────┴────────────┴──────────┴───────────┴─────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < payment_mix.sql

The first result is what SQL Server’s PIVOT (SUM(amount) FOR mode IN (...)) returns: one aggregate, and NULL in the cells where a city never used a mode. The second is what Oracle’s PIVOT with two aliased aggregates can return, a total and a count per mode, with zeros where a mode was not used. Each CASE returns the amount for one mode and NULL for the rest, so sum adds only that mode. The last column counts what any fixed list misses: Indore’s wallet payment of 120 is in no mode column.

Payment modes

The example’s modes include UPI, India’s real-time payment system, which is why a mode column can outgrow a report’s fixed list: new apps and modes appear often, and a PIVOT written for an older list of modes has no column for them.

Conditional aggregation is the form to prefer when a query must run on both engines. It takes any number of aggregates, needs no brackets or quoted names, and turns NULLs into zeros where you ask. The operators also have rules of their own: SQL Server’s PIVOT refuses COUNT(*) and returns 0 where a COUNT finds no rows, and Oracle’s names a column after the value alone when the aggregate has no alias, so the same result set can carry different column names in the two engines.

UNPIVOT and the NULL cells

UNPIVOT turns columns back into rows, and both engines leave out NULL cells by default. Oracle’s UNPIVOT INCLUDE NULLS keeps them; SQL Server has no such option:

Eight target cells, two of them NULL, turned into rows SQL · unpivot_nulls.sql
-- Quarterly targets kept one column per quarter, turned back into rows. Both engines' UNPIVOT leave out NULL cells by
-- default; Oracle's UNPIVOT INCLUDE NULLS keeps them. UNION ALL is the portable form, and its WHERE decides the same.
CREATE TABLE targets (city TEXT PRIMARY KEY, q1 INTEGER, q2 INTEGER, q3 INTEGER, q4 INTEGER);
INSERT INTO targets VALUES ('Pune', 900, 950, 1000, NULL), ('Kochi', 400, NULL, 450, 500);

WITH unpivoted (city, quarter, target) AS (
  SELECT city, 'Q1', q1 FROM targets UNION ALL
  SELECT city, 'Q2', q2 FROM targets UNION ALL
  SELECT city, 'Q3', q3 FROM targets UNION ALL
  SELECT city, 'Q4', q4 FROM targets
)
SELECT 'like UNPIVOT (the default)' AS form, count(*) AS rows_out FROM unpivoted WHERE target IS NOT NULL
UNION ALL
SELECT 'like UNPIVOT INCLUDE NULLS', count(*) FROM unpivoted;

-- The cells that the default leaves out: a quarter without a target simply disappears from the result.
WITH unpivoted (city, quarter, target) AS (
  SELECT city, 'Q1', q1 FROM targets UNION ALL
  SELECT city, 'Q2', q2 FROM targets UNION ALL
  SELECT city, 'Q3', q3 FROM targets UNION ALL
  SELECT city, 'Q4', q4 FROM targets
)
SELECT city, quarter FROM unpivoted WHERE target IS NULL ORDER BY city, quarter;

Output

┌────────────────────────────┬──────────┐
│            form            │ rows_out │
├────────────────────────────┼──────────┤
│ like UNPIVOT (the default) │ 6        │
│ like UNPIVOT INCLUDE NULLS │ 8        │
└────────────────────────────┴──────────┘
┌───────┬─────────┐
│ city  │ quarter │
├───────┼─────────┤
│ Kochi │ Q2      │
│ Pune  │ Q4      │
└───────┴─────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < unpivot_nulls.sql

Eight cells become six rows with the default rule, and two quarters without a target vanish. A report built on those rows cannot tell “no target” from “no row”, so decide which one the data means before unpivoting. The columns being turned into rows must also share a type: Oracle’s reference requires one data type group, numeric or character, so a mix of number and text columns has to be converted first.

APPLY and the window differences, in brief

APPLY is the natural form when the subquery needs its own ORDER BY and row limit for each outer row, which a plain join cannot express. The CROSS APPLY form returns the rows of the subquery for each outer row, and drops outer rows without any. Its partner, OUTER APPLY, keeps those outer rows, with NULLs. PostgreSQL writes the same as LEFT JOIN LATERAL (...) ON true. QUALIFY filters on a window function’s result in Oracle. Named windows in SQL Server need version 2022 and compatibility level 160, and QUALIFY does not exist there, so a query that filters on a window function needs a CTE around it. GROUPS frames count groups of tied rows. KEEP (DENSE_RANK FIRST) returns a value from the top-ranked rows inside a GROUP BY, in Oracle only.

Your turn

Exercise · Easy · SQL

Pivot payments by mode, the way both engines can run it

A shop's report shows, for each city, how much was paid through each payment mode. SQL Server's PIVOT and Oracle's PIVOT write it differently, so the team wants one query that both engines can run: conditional aggregation.

The table payments has the columns id, city, mode and amount. The modes in this report are 'upi', 'card' and 'cash'.

Return one row per city with the columns city, upi, card and cash: the sum of the amounts paid through each mode, and 0, not NULL, where a city has no payment through that mode.

Starter code · mode_matrix.sql

-- One row per city: the amounts paid by upi, card and cash, with 0 where a city has none.
-- This starter adds up every payment of the city in each column.
SELECT city,
       sum(amount) AS upi,
       sum(amount) AS card,
       sum(amount) AS cash
FROM payments
GROUP BY city;
The sample tests · tests.yaml
# Sample tests: each one builds a small payments table, runs your query on it and compares the rows it returns (in any
# order).
tests:
  - name: two cities, every mode used somewhere
    setup: |
      CREATE TABLE payments (id INTEGER PRIMARY KEY, city TEXT NOT NULL, mode TEXT NOT NULL, amount INTEGER NOT NULL);
      INSERT INTO payments VALUES
        (1, 'Pune', 'upi', 450), (2, 'Pune', 'upi', 1200), (3, 'Pune', 'card', 3000), (4, 'Pune', 'cash', 200),
        (5, 'Kochi', 'upi', 700), (6, 'Kochi', 'cash', 150), (7, 'Kochi', 'cash', 90);
    expected:
      columns: [city, upi, card, cash]
      rows:
        - [Pune, 1650, 3000, 200]
        - [Kochi, 700, 0, 240]
  - name: a city that paid only by card
    setup: |
      CREATE TABLE payments (id INTEGER PRIMARY KEY, city TEXT NOT NULL, mode TEXT NOT NULL, amount INTEGER NOT NULL);
      INSERT INTO payments VALUES
        (1, 'Indore', 'card', 2500), (2, 'Indore', 'card', 500), (3, 'Surat', 'upi', 80);
    expected:
      columns: [city, upi, card, cash]
      rows:
        - [Indore, 0, 3000, 0]
        - [Surat, 80, 0, 0]
  - name: modes outside the report are not counted in its columns
    setup: |
      CREATE TABLE payments (id INTEGER PRIMARY KEY, city TEXT NOT NULL, mode TEXT NOT NULL, amount INTEGER NOT NULL);
      INSERT INTO payments VALUES
        (1, 'Nagpur', 'wallet', 120), (2, 'Nagpur', 'cash', 60), (3, 'Nagpur', 'upi', 300);
    expected:
      columns: [city, upi, card, cash]
      rows:
        - [Nagpur, 300, 0, 60]
A hint

sum(CASE WHEN mode = 'upi' THEN amount END) adds only that mode's amounts, because the CASE gives NULL for every other row and sum skips NULLs. When a city has no such payment at all, the sum itself is NULL: wrap it in coalesce(..., 0).

The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 4 You need both the total and the number of payments per mode in one PIVOT. Which engine's PIVOT can do that in a single clause?

    Choose one answer.

    Show the answer to question 1

    Answer: Oracle's: it accepts several aliased aggregates, such as SUM(amount) AS total, COUNT(*) AS cnt

    SQL Server's PIVOT takes exactly one aggregate function. Oracle's accepts several, as long as all but one have an alias, and names the columns from the value's alias and the aggregate's alias, such as UPI_TOTAL and UPI_CNT.

  2. Question 2 of 4 A targets table has one row whose four quarter columns are 900, 950, 1000 and NULL. How many rows does UNPIVOT return for it with default settings?

    Type a number.

    Show the answer to question 2

    Answer: 3

    Both engines' UNPIVOT leave out NULL cells by default, so the quarter without a target disappears. Oracle's UNPIVOT INCLUDE NULLS would return all 4 rows.

  3. Question 3 of 4 Which window frame does SQL Server reject?

    Choose one answer.

    Show the answer to question 3

    Answer: RANGE BETWEEN 2 PRECEDING AND CURRENT ROW

    SQL Server allows RANGE only with UNBOUNDED PRECEDING, UNBOUNDED FOLLOWING and CURRENT ROW; a number of PRECEDING or FOLLOWING values is allowed only with ROWS. Oracle accepts RANGE with an offset.

  4. Question 4 of 4 Daily sales of 100, 80 and 80, where the two 80s share one date, are summed with SUM(amount) OVER (ORDER BY sale_date). What running total do the two rows of the shared date show?

    Type a number.

    Show the answer to question 4

    Answer: 260

    With ORDER BY and no frame, the default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which includes every row with the same date as the current one. Both rows therefore show 100 + 80 + 80. With ROWS UNBOUNDED PRECEDING they would show 180 and 260.

The top row per group, five ways compared, with ties and missing rows Pro: opens with a Pro, Premium or Ultimate pass

“The latest order of each customer” and “the highest-paid person in each department” are everyday report queries, and each engine has its own shortcuts for them. This part runs all five forms on one table and shows which ones keep ties, which keep customers without orders and which run on both engines.

In this part:

  • Five forms on one table, run side by side
  • When APPLY is the faster form, with the numbers

1 runnable example (SQL) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Window frames, rolling sums and carried-forward values, ported between the engines Pro: opens with a Pro, Premium or Ultimate pass

Window functions look the same in both engines until a frame needs an offset, a group of ties or a value that skips NULLs. This part shows the frames SQL Server rejects, a rolling sum by calendar date ported to SQL Server without changing its result, and two ways to carry the last known value forward.

In this part:

  • Frames each engine accepts
  • The default frame and the rolling sum, reproduced
  • The last known value, carried forward without IGNORE NULLS

2 runnable examples (SQL) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher to mid · Warm-up

    What do CROSS APPLY and OUTER APPLY do, and how do you write the same in Oracle and in PostgreSQL?

    APPLY runs a subquery, or a table-valued function, once for each row of the table on its left, and the subquery can use that row's columns. CROSS APPLY returns the rows the subquery produces for each outer row and drops outer rows for which it returns nothing, like an inner join. OUTER APPLY keeps those outer rows and fills the subquery's columns with NULL, like a left join. A typical use is the largest order of each customer, with TOP (1) and an ORDER BY inside the subquery. Oracle accepts CROSS APPLY and OUTER APPLY too, and also LATERAL inline views. PostgreSQL writes them as CROSS JOIN LATERAL and LEFT JOIN LATERAL (...) ON true.

  2. Mid to senior

    A monthly payments report pivots amounts by payment mode with PIVOT. Finance adds about two new modes a quarter, and the report silently leaves their payments out. Design a fix that works in SQL Server and in Oracle.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Mid to senior

    An Oracle query computes a rolling 7-day sales total with SUM(amount) OVER (ORDER BY sale_date RANGE BETWEEN INTERVAL '6' DAY PRECEDING AND CURRENT ROW). Port it to SQL Server so the results stay identical, including days without sales.

    Model answer, Pro. First step and follow-ups

  4. Fresher to senior

    For each department, return the highest-paid employee, and if two people share the top salary, return both. Write it for SQL Server and Oracle, and compare at least three ways of doing it.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • SQL Server’s PIVOT takes one aggregate; Oracle’s takes several. Conditional aggregation runs on both.
  • UNPIVOT drops NULL cells by default in both engines; Oracle can keep them with INCLUDE NULLS.
  • CROSS APPLY and OUTER APPLY work in both engines, and PostgreSQL writes them with LATERAL.
  • SQL Server allows RANGE frames only up to UNBOUNDED and CURRENT ROW, and has no GROUPS or QUALIFY.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.