PostgreSQL

Window Functions in PostgreSQL vs Oracle: Same Power, Better SQL

Rakesh Mamidala·Founder & Lead Engineer··7 min read

The Good News: Window Functions Port Almost Verbatim

Of all the things that make an Oracle→PostgreSQL migration painful — packages, sequences, CONNECT BY, LOBs — window functions are refreshingly not one of them. The analytic syntax you already know from Oracle is the ANSI SQL standard, and PostgreSQL implements it faithfully. ROW_NUMBER, RANK, DENSE_RANK, NTILE, LAG, LEAD, FIRST_VALUE, running totals with OVER (PARTITION BY … ORDER BY …) — all present, same names, same semantics.

sql
-- This query is byte-for-byte identical in Oracle and PostgreSQL
SELECT
  employee_id,
  department_id,
  salary,
  RANK()       OVER (PARTITION BY department_id ORDER BY salary DESC) AS dept_rank,
  LAG(salary)  OVER (PARTITION BY department_id ORDER BY hire_date)   AS prev_hire_salary,
  SUM(salary)  OVER (PARTITION BY department_id ORDER BY hire_date
                     ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
FROM employees;

If your reports lean on analytics, that’s most of your window-function surface migrating with zero edits. The value of this post is the remaining 10% — three differences that pass a syntax check and then return the wrong answer, or don’t exist at all.

The LAST_VALUE Frame Trap (Both Databases)

This one is not an Oracle-vs-PostgreSQL difference — it’s a trap they share, which is exactly why it survives migration unnoticed. When you add ORDER BY to a window without an explicit frame, the default frame in both databases is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. So FIRST_VALUE works as you’d expect, but LAST_VALUE returns the current row’s value — because “the last row” in a frame that ends at the current row is the current row.

sql
-- WRONG: last_salary just echoes the current row's salary
SELECT employee_id,
       LAST_VALUE(salary) OVER (PARTITION BY department_id ORDER BY hire_date) AS last_salary
FROM employees;

-- RIGHT: widen the frame to the whole partition
SELECT employee_id,
       LAST_VALUE(salary) OVER (PARTITION BY department_id ORDER BY hire_date
                                ROWS BETWEEN UNBOUNDED PRECEDING
                                         AND UNBOUNDED FOLLOWING) AS last_salary
FROM employees;

If a query used LAST_VALUE correctly in Oracle, it already had the explicit frame and it carries straight over. The danger is code that was subtly wrong in Oracle too — migration is a good moment to fix it rather than faithfully reproduce a bug.

Oracle-Only: KEEP (DENSE_RANK FIRST/LAST)

Oracle’s KEEP … (DENSE_RANK FIRST/LAST ORDER BY …) is a genuinely Oracle-specific aggregate — a compact way to say “of the rows in this group, give me the MAX(salary) belonging to the earliest hire_date.” PostgreSQL has no KEEP clause, so this is one of the few window-adjacent constructs you must actually rewrite.

sql
-- Oracle: salary of the most-recently-hired employee per department
SELECT department_id,
       MAX(salary) KEEP (DENSE_RANK LAST ORDER BY hire_date) AS newest_hire_salary
FROM employees
GROUP BY department_id;

-- PostgreSQL: a window function + DISTINCT ON does the same job
SELECT DISTINCT ON (department_id)
       department_id,
       salary AS newest_hire_salary
FROM employees
ORDER BY department_id, hire_date DESC;

DISTINCT ON is a PostgreSQL superpower Oracle lacks: it keeps the first row per group given an ORDER BY, which covers most KEEP use cases in one clean statement. For multiple aggregates in the same group, a subquery with ROW_NUMBER() filtered to = 1 is the general-purpose rewrite.

IGNORE NULLS: Native Since PostgreSQL 17

Oracle has long let you write LAG(x) IGNORE NULLS or LAST_VALUE(x IGNORE NULLS) to skip over nulls when carrying a value forward — the classic “last known price” pattern. This was a real gap in PostgreSQL for years, and PostgreSQL 17 closes it: IGNORE NULLS / RESPECT NULLS now work on lead, lag, first_value, last_value and nth_value.

sql
-- PostgreSQL 17+: direct equivalent of Oracle's IGNORE NULLS
SELECT ts, price,
       LAST_VALUE(price) IGNORE NULLS OVER (ORDER BY ts
                                            ROWS BETWEEN UNBOUNDED PRECEDING
                                                     AND CURRENT ROW) AS last_known_price
FROM ticks;

-- Pre-17 workaround: bucket by a running count of non-null values
SELECT ts,
       MAX(price) OVER (PARTITION BY grp) AS last_known_price
FROM (
  SELECT ts, price,
         COUNT(price) OVER (ORDER BY ts) AS grp   -- only increments on non-null
  FROM ticks
) s;

If your target is PostgreSQL 17 or newer — and for a fresh migration in 2026 it should be — you can port IGNORE NULLS as-is. On an older target, the running-count bucketing trick above is the portable rewrite.

Two Smaller Ones: RATIO_TO_REPORT and the WINDOW Clause

Oracle’s RATIO_TO_REPORT(x) has no PostgreSQL function, but it’s just a share-of-total — one line with a plain window SUM:

sql
-- Oracle:  RATIO_TO_REPORT(amount) OVER (PARTITION BY region)
-- PostgreSQL:
SELECT region, amount,
       amount::numeric / SUM(amount) OVER (PARTITION BY region) AS ratio
FROM sales;

And a quality-of-life win worth adopting while you’re rewriting: PostgreSQL (and Oracle) support a named WINDOW clause, so a query that reuses the same partition three times states it once. It makes the migrated SQL easier to read than the original — the “better SQL” in the title.

sql
SELECT employee_id,
       RANK()       OVER w AS dept_rank,
       DENSE_RANK() OVER w AS dept_dense_rank,
       AVG(salary)  OVER w AS dept_avg
FROM employees
WINDOW w AS (PARTITION BY department_id ORDER BY salary DESC);

Analytics that migrate on day one

DBMigrateAIPro converts Oracle schema, PL/SQL and data in one pass — and flags the handful of constructs (like KEEP … DENSE_RANK) that need a human rewrite, instead of silently shipping SQL that compiles but returns the wrong number. Free for Year 1.