Advanced PostgreSQL

PostgreSQL Window Functions Tutorial

Perform calculations across related rows with ROW_NUMBER, RANK, LAG, LEAD, and custom frames.

7 min read Tutorial

Window functions perform calculations across a set of rows related to the current row, without collapsing them into a single output row like GROUP BY does. They are one of the most powerful features in SQL.

ROW_NUMBER, RANK, and DENSE_RANK

-- Number rows within each category by price
SELECT
  name,
  category,
  price,
  ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS row_num,
  RANK()       OVER (PARTITION BY category ORDER BY price DESC) AS rank,
  DENSE_RANK() OVER (PARTITION BY category ORDER BY price DESC) AS dense_rank
FROM products;

ROW_NUMBER assigns a unique sequential number. RANK leaves gaps for ties (1, 2, 2, 4). DENSE_RANK does not leave gaps (1, 2, 2, 3). All three partition independently within each category.

LAG and LEAD

-- Compare each month's revenue to the previous month
SELECT
  DATE_TRUNC('month', created_at) AS month,
  SUM(total) AS revenue,
  LAG(SUM(total)) OVER (ORDER BY DATE_TRUNC('month', created_at)) AS prev_month,
  ROUND(
    (SUM(total) - LAG(SUM(total)) OVER (ORDER BY DATE_TRUNC('month', created_at)))
    / LAG(SUM(total)) OVER (ORDER BY DATE_TRUNC('month', created_at)) * 100,
    1
  ) AS growth_pct
FROM orders
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY month;

LAG accesses the previous row's value and LEAD accesses the next row's value. They are invaluable for calculating period-over-period changes.

Running Totals and Moving Averages

-- Running total of revenue
SELECT
  created_at::date AS day,
  total,
  SUM(total) OVER (ORDER BY created_at) AS running_total
FROM orders;

-- 7-day moving average
SELECT
  day,
  revenue,
  ROUND(AVG(revenue) OVER (
    ORDER BY day
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
  ), 2) AS moving_avg_7d
FROM daily_revenue;

Practical Example: Top N per Group

-- Top 3 products per category by revenue
SELECT * FROM (
  SELECT
    p.name,
    p.category,
    SUM(oi.quantity * oi.unit_price) AS revenue,
    ROW_NUMBER() OVER (
      PARTITION BY p.category
      ORDER BY SUM(oi.quantity * oi.unit_price) DESC
    ) AS rn
  FROM order_items oi
  JOIN products p ON oi.product_id = p.id
  GROUP BY p.id, p.name, p.category
) ranked
WHERE rn <= 3;

Common Use Cases

  • Ranking items within groups (top products per category, leaderboards)
  • Month-over-month or week-over-week growth calculations
  • Running totals, cumulative sums, and moving averages
  • Deduplication by selecting only the most recent row per group

Try this query in UnifySQL

Write, optimize, and collaborate on PostgreSQL queries with AI assistance.

Start Free