SQL - NTH_VALUE() Function
The `NTH_VALUE()` function returns the value of an expression from the nth row in an ordered set of rows within a window partition. The basic syntax:
Key Insights
NTH_VALUE()retrieves the value of a specified row within an ordered partition, enabling access to any position in a window frame without self-joins or complex subqueries- Unlike
FIRST_VALUE()andLAST_VALUE(),NTH_VALUE()provides flexible positional access with explicit control over null handling and frame boundaries - Most effective when combined with proper window frame specifications (
ROWS BETWEENorRANGE BETWEEN) to avoid unexpected results from default frame behavior
Understanding NTH_VALUE() Syntax
The NTH_VALUE() function returns the value of an expression from the nth row in an ordered set of rows within a window partition. The basic syntax:
NTH_VALUE(expression, n)
[FROM FIRST | FROM LAST]
[RESPECT NULLS | IGNORE NULLS]
OVER (
[PARTITION BY partition_expression]
ORDER BY sort_expression
[frame_clause]
)
The function requires an ORDER BY clause within the OVER() specification. The n parameter must be a positive integer indicating which row to retrieve.
-- Basic example with sales data
SELECT
sale_date,
amount,
NTH_VALUE(amount, 2) OVER (
ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS second_sale_amount
FROM sales
ORDER BY sale_date;
Frame Specification Critical Behavior
The default window frame for NTH_VALUE() is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which often produces unexpected results. Always specify an explicit frame clause.
-- Without explicit frame (problematic)
SELECT
employee_id,
salary,
NTH_VALUE(salary, 3) OVER (ORDER BY salary DESC) AS third_highest
FROM employees;
-- With explicit frame (correct)
SELECT
employee_id,
salary,
NTH_VALUE(salary, 3) OVER (
ORDER BY salary DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS third_highest
FROM employees;
The first query returns NULL for the first two rows because the window frame hasn’t reached the third row yet. The second query correctly returns the third-highest salary for all rows.
Comparing Multiple Positions
Access multiple positions within the same dataset to perform comparative analysis:
WITH product_sales AS (
SELECT
product_id,
sale_date,
revenue,
ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY sale_date) AS sale_number
FROM sales
)
SELECT
product_id,
sale_date,
revenue,
NTH_VALUE(revenue, 1) OVER w AS first_sale,
NTH_VALUE(revenue, 5) OVER w AS fifth_sale,
NTH_VALUE(revenue, 10) OVER w AS tenth_sale,
revenue - NTH_VALUE(revenue, 1) OVER w AS growth_from_first
FROM product_sales
WINDOW w AS (
PARTITION BY product_id
ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
);
FROM FIRST vs FROM LAST
Control whether counting starts from the beginning or end of the partition:
CREATE TABLE stock_prices (
ticker VARCHAR(10),
price_date DATE,
closing_price DECIMAL(10,2)
);
-- Compare first and last approaches
SELECT
ticker,
price_date,
closing_price,
NTH_VALUE(closing_price, 3) FROM FIRST OVER w AS third_from_start,
NTH_VALUE(closing_price, 3) FROM LAST OVER w AS third_from_end
FROM stock_prices
WINDOW w AS (
PARTITION BY ticker
ORDER BY price_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)
ORDER BY ticker, price_date;
FROM FIRST is the default behavior. FROM LAST reverses the counting direction, useful for analyzing recent trends while maintaining forward chronological ordering.
Handling NULL Values
The RESPECT NULLS and IGNORE NULLS clauses determine how NULL values affect position counting:
CREATE TABLE sensor_readings (
sensor_id INT,
reading_time TIMESTAMP,
temperature DECIMAL(5,2)
);
-- RESPECT NULLS (default) - NULLs count as positions
SELECT
sensor_id,
reading_time,
temperature,
NTH_VALUE(temperature, 3) RESPECT NULLS OVER (
PARTITION BY sensor_id
ORDER BY reading_time
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS third_reading_with_nulls
FROM sensor_readings;
-- IGNORE NULLS - skip NULL values in counting
SELECT
sensor_id,
reading_time,
temperature,
NTH_VALUE(temperature, 3) IGNORE NULLS OVER (
PARTITION BY sensor_id
ORDER BY reading_time
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS third_valid_reading
FROM sensor_readings;
IGNORE NULLS proves valuable when working with sparse data where NULL represents missing measurements rather than meaningful values.
Practical Use Case: Cohort Analysis
Analyze user behavior by comparing early actions with later outcomes:
WITH user_purchases AS (
SELECT
user_id,
purchase_date,
amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY purchase_date) AS purchase_seq
FROM purchases
)
SELECT
user_id,
COUNT(*) AS total_purchases,
NTH_VALUE(amount, 1) OVER w AS first_purchase_amount,
NTH_VALUE(amount, 2) OVER w AS second_purchase_amount,
NTH_VALUE(purchase_date, 1) OVER w AS first_purchase_date,
NTH_VALUE(purchase_date, 2) OVER w AS second_purchase_date,
DATEDIFF(
NTH_VALUE(purchase_date, 2) OVER w,
NTH_VALUE(purchase_date, 1) OVER w
) AS days_to_second_purchase
FROM user_purchases
WINDOW w AS (
PARTITION BY user_id
ORDER BY purchase_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)
GROUP BY user_id;
Performance Optimization
Window functions can be resource-intensive. Optimize with proper indexing and partition pruning:
-- Create appropriate indexes
CREATE INDEX idx_sales_product_date ON sales(product_id, sale_date);
-- Use CTEs to limit data before windowing
WITH recent_sales AS (
SELECT *
FROM sales
WHERE sale_date >= CURRENT_DATE - INTERVAL '90 days'
)
SELECT
product_id,
sale_date,
amount,
NTH_VALUE(amount, 5) OVER (
PARTITION BY product_id
ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS fifth_recent_sale
FROM recent_sales;
Combining with CASE for Conditional Logic
Extract different positions based on runtime conditions:
SELECT
department_id,
employee_id,
salary,
performance_rating,
CASE
WHEN performance_rating >= 4 THEN
NTH_VALUE(salary, 1) OVER (
PARTITION BY department_id
ORDER BY salary DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)
ELSE
NTH_VALUE(salary, 3) OVER (
PARTITION BY department_id
ORDER BY salary DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)
END AS benchmark_salary
FROM employees;
Alternative Approaches
Before NTH_VALUE() became widely available, developers used complex workarounds:
-- Old approach with self-join and ROW_NUMBER
SELECT
e1.employee_id,
e1.salary,
e2.salary AS third_highest
FROM employees e1
CROSS JOIN (
SELECT salary
FROM (
SELECT salary, ROW_NUMBER() OVER (ORDER BY salary DESC) AS rn
FROM employees
) ranked
WHERE rn = 3
) e2;
-- Modern approach with NTH_VALUE
SELECT
employee_id,
salary,
NTH_VALUE(salary, 3) OVER (
ORDER BY salary DESC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS third_highest
FROM employees;
The NTH_VALUE() approach is clearer, more maintainable, and typically performs better as it requires only a single table scan.
Database Compatibility
Support varies across database systems. PostgreSQL, Oracle, and SQL Server support the full specification. MySQL added support in version 8.0. Always verify frame clause syntax for your specific database, as some systems use different defaults or have limited frame specification options.