A very common type of SQL query is the TOP-N query, where we need the “TOP N” records ordered by some value, possibly per category. In this blog post, we’re going to look into a variety of different aspects to this problem, as well as how to solve them with standard and non-standard SQL.

These are the different aspects we’ll discuss:

Top values

Top values with ties

Top values per category

Getting the Top Value

When looking at the Sakila database, we might want to find the actor who played in the most films. The simplest solution here would be to use GROUP BY to find the number of films per actor, and then ORDER BY and LIMIT to find the “TOP 1” actor.

Here’s the query in PostgreSQL:

SELECT actor_id, first_name, last_name, count(film_id) FROM actor LEFT JOIN film_actor USING (actor_id) GROUP BY actor_id, first_name, last_name ORDER BY count(film_id) DESC LIMIT 1;

Yielding

ACTOR_ID FIRST_NAME LAST_NAME COUNT -------------------------------------- 107 GINA DEGENERES 42

Other databases have different syntaxes for LIMIT – check out the jOOQ manual for a complete list of emulations of this useful clause, e.g. in Oracle 12c, we would use FETCH :

SELECT actor_id, first_name, last_name, count(film_id) FROM actor LEFT JOIN film_actor USING (actor_id) GROUP BY actor_id, first_name, last_name ORDER BY count(*) DESC FETCH FIRST ROW ONLY;

Or, in SQL Server, we could use TOP

SELECT TOP 1 a.actor_id, first_name, last_name, count(film_id) FROM actor a LEFT JOIN film_actor fa ON a.actor_id = fa.actor_id GROUP BY a.actor_id, first_name, last_name ORDER BY count(*) DESC;

… which kinda makes sense. We want the TOP 1 actor, right?

Alternatives with Subqueries

In my SQL Masterclass, we’re also looking at the performance of various variants of solving this particular problem. For instance, we could calculate the COUNT(*) value in a derived value, prior to joining:

SELECT actor_id, first_name, last_name, COALESCE(c, 0) FROM actor LEFT JOIN ( SELECT actor_id, count(*) AS c FROM film_actor GROUP BY actor_id ) fa USING (actor_id) ORDER BY COALESCE(c, 0) DESC LIMIT 1;

Or, we could calculate the COUNT(*) value in a correlated subquery:

SELECT actor_id, first_name, last_name, ( SELECT count(*) FROM film_actor fa WHERE fa.actor_id = a.actor_id ) AS c FROM actor a ORDER BY c DESC LIMIT 1;

These perform vastly differently, depending on the database as can be seen in this tweet:

From my updated SQL Masterclass:

What's the "best" technique to aggregate a value using SQL?

Answer: It depends

Always measure (yourself) 1/ pic.twitter.com/akLvBXEJD1 — Lukas Eder (@lukaseder) August 4, 2017

Anyway, the different techniques do not really influence the TOP-N semantics themselves, as we’re always using the same two clauses to get the TOP 1 actor:

ORDER BY c DESC -- Some means of calculating "c" LIMIT 1; -- Some means of limiting results to 1 row

Window function alternative

In the old days when databases like Oracle didn’t really support LIMIT (or when using DB2, SQL Server, and Sybase, which support “LIMIT” but not “OFFSET”), people used to resort to using window functions. In order to get the FETCH FIRST n ROWS ONLY semantics, we can use ROW_NUMBER() :

SELECT * FROM ( SELECT actor_id, first_name, last_name, count(film_id), row_number() OVER (ORDER BY count(film_id) DESC) rn FROM actor LEFT JOIN film_actor USING (actor_id) GROUP BY actor_id, first_name, last_name ) t WHERE rn <= 5 ORDER BY rn;

The derived table contains that ROW_NUMBER() window function, which produces a unique, consecutive row number for each actor ordered by the number of films the actor played in.

Note that thanks to the aggregation step “happening before” the windowing step, we can use aggregation functions inside of window functions.

We’ll revisit the window function approach again later, when implementing “WITH TIES” semantics using RANK() .

Oracle Specific Alternative

In Oracle, we have those cool FIRST and LAST functions, with a bit of an arcane syntax. They help finding the first or last element in a sorted group.

SELECT max(actor_id) KEEP (DENSE_RANK FIRST ORDER BY c DESC, actor_id), max(first_name) KEEP (DENSE_RANK FIRST ORDER BY c DESC, actor_id), max(last_name) KEEP (DENSE_RANK FIRST ORDER BY c DESC, actor_id), max(c) KEEP (DENSE_RANK FIRST ORDER BY c DESC, actor_id) FROM ( SELECT actor_id, first_name, last_name, count(film_id) c FROM actor LEFT JOIN film_actor USING (actor_id) GROUP BY actor_id, first_name, last_name ) t;

It’s fair to say that the syntax is a bit verbose.

How does this work? The nested query counts again all the films per actor, producing the entire result set. The outer query, however, aggregates all these actors and their film counts into a single row, displaying in that row only the first value(s) within the group, ordered by some specific ordering criteria. Note that the ACTOR_ID was also added to the ordering criteria, to get a unique ordering (see “with ties” semantics, below).

Why do we need the MAX() aggregate function? Simply because this feature applies only to aggregate functions, and the FIRST value(s) could be more than one value within the group, namely when the ordering would produce a “tie” (again, see below).

I’ve seen this slightly outperform the alternatives in TOP 1 cases (that ignore “TIES”, see below), including this one. A benchmark showing relative execution times shows:

Statement 1 : 1.88945 (FETCH version) Statement 2 : 1.84442 (window function version) Statement 3 : 1 (KEEP version)

The benchmark technique is described in this blog, on this page, and also again below. Measure yourself though!

Getting the Top Value “With Ties”

In the previous query, we’re getting a single top value, and the result is correct as we can see with this query that returns the TOP 5 values:

SELECT actor_id, first_name, last_name, count(film_id) FROM actor LEFT JOIN film_actor USING (actor_id) GROUP BY actor_id, first_name, last_name ORDER BY count(film_id) DESC LIMIT 5;

The result being:

ACTOR_ID FIRST_NAME LAST_NAME COUNT -------------------------------------- 107 GINA DEGENERES 42 102 WALTER TORN 41 198 MARY KEYTEL 40 181 MATTHEW CARREY 39 23 SANDRA KILMER 37

But what if WALTER TORN had played in yet one more film? We would have a “tie” between GINA DEGENERES and WALTER TORN, so which one would we pick as the TOP 1 actor? There are several possible answers:

Pick a random one by accident: This is what our query does right now. This might be considered fair, as the preference for the winner might be merely technical and might change between releases

Pick a random one explicitly: We could add another sort criteria that introduces randomness (e.g. a RANDOM() function call). Let’s just hope that this call is only made when needed, i.e. when we have a tie. Otherwise, that would be rather dangerous. But it would be fair.

Specify a unique additional sort criteria: E.g. the ACTOR_ID. That would make the result predictable, but in this case a bit unfair.

Fetch both tied actors. Yes, we can have several winners. That’s what sports events do, and that’s what we’ll discuss now.

So, let’s add one more film for WALTER TORN:

INSERT INTO film_actor (actor_id, film_id) VALUES (102, 1);

The SQL standard specifies how we can fetch the first rows “with their ties”, namely by using … well … the FETCH FIRST ROWS WITH TIES syntax!

This is implemented as such in Oracle 12c, the only database I’ve seen with this standard syntax so far.

SELECT actor_id, first_name, last_name, count(film_id) FROM actor LEFT JOIN film_actor USING (actor_id) GROUP BY actor_id, first_name, last_name ORDER BY count(film_id) DESC FETCH FIRST ROWS WITH TIES;

Note that all of these syntaxes are possible, they’re all equivalent. Use the one that resembles the English language the most, in your opinion (in other words: everyone does it differently):

FETCH FIRST 1 ROWS WITH TIES FETCH FIRST 1 ROW WITH TIES FETCH FIRST ROWS WITH TIES FETCH FIRST ROW WITH TIES FETCH NEXT 1 ROWS WITH TIES FETCH NEXT 1 ROW WITH TIES FETCH NEXT ROWS WITH TIES FETCH NEXT ROW WITH TIES

The only other database I’m aware of that knows this feature (but not the standard syntax) is SQL Server:

SELECT TOP 1 WITH TIES a.actor_id, first_name, last_name, count(*) FROM actor a JOIN film_actor fa ON a.actor_id = fa.actor_id GROUP BY a.actor_id, first_name, last_name ORDER BY count(*) DESC;

While SQL Server also supports the standard OFFSET .. FETCH syntax for the ROWS ONLY semantics (i.e. pick a random row among the winners), it supports WITH TIES only with the proprietary TOP syntax.

That’s OK, we can always use TOP , because OFFSET and OFFSET pagination is verboten anyway, right?

Using Window Functions in Other Databases

As promised, we’re now revisiting a window function based solution to make the “WITH TIES” semantics work in other databases as well. We can make use of ranking functions, namely RANK() (or in more rare cases DENSE_RANK() ).

Here’s how to find the TOP 1 actor WITH TIES in PostgreSQL, for instance:

SELECT * FROM ( SELECT actor_id, first_name, last_name, count(film_id), rank() OVER (ORDER BY count(film_id) DESC) rk FROM actor LEFT JOIN film_actor USING (actor_id) GROUP BY actor_id, first_name, last_name ) t WHERE rk = 1;

We’re now nesting the original query in a derived table, adding an additional column RK, which contains the RANK() of the row given the desired ordering. The result is:

ACTOR_ID FIRST_NAME LAST_NAME COUNT RK ------------------------------------------ 107 GINA DEGENERES 42 1 102 WALTER TORN 42 1

If we wanted to find the TOP 5 again, we simply search for ranks less or equal to 5. And don’t forget explicit ordering again. Even if the ordering from the subquery will probably be stable (namely the one from the window function, which was the last one applied to the subquery), we must never rely on a non-explicit ordering. NEVER.

SELECT * FROM ( SELECT actor_id, first_name, last_name, count(film_id), rank() OVER (ORDER BY count(film_id) DESC) rk FROM actor LEFT JOIN film_actor USING (actor_id) GROUP BY actor_id, first_name, last_name ) t WHERE rk <= 5 ORDER BY rk;

ACTOR_ID FIRST_NAME LAST_NAME COUNT RK ------------------------------------------ 107 GINA DEGENERES 42 1 102 WALTER TORN 42 1 198 MARY KEYTEL 40 3 181 MATTHEW CARREY 39 4 23 SANDRA KILMER 37 5

Now, if we had another actor with 37 films, that other actor would also appear in this list and the list would contain 6 actors, even if we searched for the TOP 5 (WITH TIES). Cool, eh?

Oracle Specific Solution

Remember we were discussing the Oracle-specific FIRST and LAST functions? Unfortunately, this cannot be used to get the WITH TIES semantics because the aggregation can produce only a single row, not multiple tied rows.

As a workaround, I’ve tried combining LISTAGG() with KEEP with no avail:

SELECT LISTAGG (first_name, ', ') WITHIN GROUP (ORDER BY count(film_id) DESC) KEEP (DENSE_RANK FIRST ORDER BY count(film_id) DESC), ...

While this could have produced all the ties in a comma separated list of values, this syntax is simply not allowed.

Top Values Per Category

Now, this is the coolest! So far, we’ve run single queries getting the single TOP N values over our entire data set. I.e. the actor with the most films.

Imagine, however, we’d like to get the TOP N something per actor. For instance, the TOP 3 most successful films an actor played in. That would be quite a query. To keep it simple on the Sakila database, let’s simply find the TOP 3 films with the longest titles per actor.

This will again use LIMIT , but this time, we need to do it in a subquery. The two tools we’ve seen so far won’t really work well:

Derived tables (subqueries in the FROM clause) cannot implement the per actor semantics easily, at least not with LIMIT. It’s possible with window functions as we’ll see later.

clause) cannot implement the per actor semantics easily, at least not with LIMIT. It’s possible with window functions as we’ll see later. Correlated subqueries (subqueries in the SELECT or WHERE clauses) can only return one row and one column. Not quite useful when we want to return, e.g. the TOP 3 rows.

Luckily, the SQL standard specifies LATERAL (implemented by Oracle 12c, PostgreSQL, DB2) and SQL Server has always had APPLY (also available in Oracle 12c). Both syntaxes are exactly equivalent. Let’s look at APPLY first, as I much prefer this syntax:

SELECT actor_id, first_name, last_name, title FROM actor a OUTER APPLY ( SELECT title FROM film f JOIN film_actor fa USING (film_id) WHERE fa.actor_id = a.actor_id ORDER BY length(title) DESC FETCH FIRST 3 ROWS ONLY ) t ORDER BY actor_id, length(title) DESC;

This will produce:

ACTOR_ID FIRST_NAME LAST_NAME TITLE ------------------------------------------------------ 1 PENELOPE GUINESS BULWORTH COMMANDMENTS 1 PENELOPE GUINESS ANACONDA CONFESSIONS 1 PENELOPE GUINESS GLEAMING JAWBREAKER 2 NICK WAHLBERG GOODFELLAS SALUTE 2 NICK WAHLBERG DESTINY SATURDAY 2 NICK WAHLBERG ADAPTATION HOLES 3 ED CHASE ARTIST COLDBLOODED 3 ED CHASE NECKLACE OUTBREAK 3 ED CHASE BOONDOCK BALLROOM ...

Cool. How does it work?

Think of the derived table as a function. A function with a single argument:

SELECT actor_id, first_name, last_name, title FROM actor a OUTER APPLY ( SELECT title FROM film f JOIN film_actor fa USING (film_id) WHERE fa.actor_id = a.actor_id -- this is the function argument ORDER BY length(title) DESC FETCH FIRST 3 ROWS ONLY ) t ORDER BY actor_id, length(title) DESC;

Or, if we would be storing this as an actual table valued function, e.g. in PostgreSQL syntax:

CREATE FUNCTION top_3_films_per_actor( p_actor_id IN INTEGER ) RETURNS TABLE ( title VARCHAR(50) ) AS $$ SELECT title FROM film f JOIN film_actor fa USING (film_id) WHERE fa.actor_id = p_actor_id ORDER BY length(title) DESC LIMIT 3 $$ LANGUAGE sql;

Table valued functions are pretty cool. They work like parameterised views in case your database can inline it. DB2 and SQL Server can, Oracle 12c and PostgreSQL 9.6 cannot. It’s not a surprise in Oracle, which doesn’t support SQL functions, only PL/SQL functions.

The function can now be used as such:

SELECT actor_id, first_name, last_name, title FROM actor a LEFT JOIN LATERAL top_3_films_per_actor(a.actor_id) t ON 1 = 1 -- Silly predicate is required because of LEFT JOIN ORDER BY actor_id, length(title) DESC;

Note that the LATERAL syntax is exactly the same as APPLY with a bit more syntactic ceremony, especially when used with OUTER JOIN .

Now, normally, we aren’t allowed to do this. We aren’t allowed to access the column A.ACTOR_ID from within a derived table or function expression. Inside the derived table, the table A is not defined, and the outer scope is not accessible either.

APPLY (and LATERAL ) changes this. With these tools, we can now access all the tables and their columns to the left of the APPLY (or LATERAL ) operator. This means our subquery now works like a correlated subquery, but it is allowed to return:

More than one row

More than one column

That’s really cool! If you’re working with Java 8 streams, think of it as the equivalent of the Stream.flatMap() operation. If we wanted to write this query in Java with streams, we could write something like:

actors .stream() .flatMap(a -> films .stream() .filter(f -> f.hasActor(a)) // Assuming some API .sorted(comparing(f -> f.title.length()).reversed()) .limit(3) .map(f -> tuple(a, f)));

This would be more or less the same, with a few simplifications. E.g. the FILM_ACTOR JOIN has been cheated away with an auxiliary method Film.hasActor(Actor) .

So, following the flatMap() “metaphor”, APPLY applies a table function (e.g. a subquery) to another table (in our case: ACTOR ). That function produces a table for each record of the ACTOR table. In our case, we chose OUTER APPLY (instead of CROSS APPLY ) because like a LEFT JOIN that will keep the actors without films in the result set – something that isn’t as easy to do with flatMap() , which corresponds to CROSS APPLY .

For more info about LATERAL and APPLY read these interesting posts:

WITH TIES Semantics

What if we wanted to list the TOP 3 films by their longest title per actor WITH TIES? I.e. if there are several films of equal length, we might get 4 or 5 or more films for any given actor?

In databases that support the WITH TIES syntax, this is really simple. In Oracle, just replace ROWS ONLY by ROWS WITH TIES :

SELECT actor_id, first_name, last_name, title FROM actor a OUTER APPLY ( SELECT title FROM film f JOIN film_actor fa USING (film_id) WHERE fa.actor_id = a.actor_id ORDER BY length(title) DESC FETCH FIRST 3 ROWS WITH TIES ) t ORDER BY actor_id, length(title) DESC;

In SQL Server, add the clause to the TOP clause:

SELECT actor_id, first_name, last_name, title FROM actor a OUTER APPLY ( SELECT TOP 3 WITH TIES title FROM film f JOIN film_actor fa ON f.film_id = fa.film_id WHERE fa.actor_id = a.actor_id ORDER BY len(title) DESC ) t ORDER BY actor_id, len(title) DESC;

In both cases, we’re now getting:

ACTOR_ID FIRST_NAME LAST_NAME TITLE ------------------------------------------------------ 1 PENELOPE GUINESS BULWORTH COMMANDMENTS 1 PENELOPE GUINESS ANACONDA CONFESSIONS 1 PENELOPE GUINESS GLEAMING JAWBREAKER 1 PENELOPE GUINESS WESTWARD SEABISCUIT 2 NICK WAHLBERG GOODFELLAS SALUTE 2 NICK WAHLBERG ADAPTATION HOLES 2 NICK WAHLBERG WARDROBE PHANTOM 2 NICK WAHLBERG RUSHMORE MERMAID 2 NICK WAHLBERG HAPPINESS UNITED 2 NICK WAHLBERG FIGHT JAWBREAKER 2 NICK WAHLBERG DESTINY SATURDAY 3 ED CHASE ARTIST COLDBLOODED 3 ED CHASE NECKLACE OUTBREAK 3 ED CHASE BOONDOCK BALLROOM ...

Quite a different result!

If we don’t have native support for WITH TIES , then we can use RANK() again, and this time, we don’t need APPLY or LATERAL :

SELECT actor_id, first_name, last_name, title FROM actor a LEFT JOIN ( SELECT actor_id, title, rank() OVER ( PARTITION BY actor_id ORDER BY length(title) DESC ) rk FROM film f JOIN film_actor fa USING (film_id) ) t USING (actor_id) WHERE rk <= 3 ORDER BY actor_id, rk;

What are we doing here, compared to the APPLY version of the query?

First off, we’re no longer filtering anything in the subquery. The original WHERE clause that accessed the outer A.ACTOR_ID column is gone

clause that accessed the outer column is gone … instead, we’re using that ACTOR_ID column in an ordinary JOIN predicate between the ACTOR table and our derived table that produces the films

column in an ordinary JOIN predicate between the table and our derived table that produces the films We calculate the RANK() of films per actor. Unlike before, where we calculated the RANK() over the entire data set (the default PARTITION in window functions is always the entire data set), we now partition our data set by ACTOR_ID . So, for each actor, we’re getting the TOP 3 (and 4 and 5 and 6, …) films pre-calculated

of films per actor. Unlike before, where we calculated the over the entire data set (the default in window functions is always the entire data set), we now partition our data set by . So, for each actor, we’re getting the TOP 3 (and 4 and 5 and 6, …) films pre-calculated … only then, in the outer query, we can filter again by this pre-calculated rank, hoping that the database will be smart enough and somehow push down the predicate into the ranking function.

Which is the faster solution? NEVER GUESS! ALWAYS MEASURE! :)

Benchmarking time

As a matter of fact, FETCH is just syntax sugar for filtering on window functions in Oracle. So the two queries should really perform equally well. I’m using the benchmarking technique from this article here.

Oracle 12.2.0.1.0 first:

Here’s the full code for Oracle:

SET SERVEROUTPUT ON CREATE TABLE results ( run NUMBER(2), stmt NUMBER(2), elapsed NUMBER ); DECLARE v_ts TIMESTAMP WITH TIME ZONE; v_repeat CONSTANT NUMBER := 100; BEGIN -- Repeat benchmark several times to avoid warmup penalty FOR r IN 1..5 LOOP v_ts := SYSTIMESTAMP; FOR i IN 1..v_repeat LOOP FOR rec IN ( SELECT actor_id, first_name, last_name, title FROM actor a OUTER APPLY ( SELECT title FROM film f JOIN film_actor fa USING (film_id) WHERE fa.actor_id = a.actor_id ORDER BY length(title) DESC FETCH FIRST 3 ROWS WITH TIES ) t ORDER BY actor_id, length(title) DESC ) LOOP NULL; END LOOP; END LOOP; INSERT INTO results VALUES (r, 1, SYSDATE + ((SYSTIMESTAMP - v_ts) * 86400) - SYSDATE); v_ts := SYSTIMESTAMP; FOR i IN 1..v_repeat LOOP FOR rec IN ( SELECT actor_id, first_name, last_name, title FROM actor a LEFT JOIN ( SELECT actor_id, title, rank() OVER ( PARTITION BY actor_id ORDER BY length(title) DESC ) rk FROM film f JOIN film_actor fa USING (film_id) ) t USING (actor_id) WHERE rk <= 3 ORDER BY actor_id, rk ) LOOP NULL; END LOOP; END LOOP; INSERT INTO results VALUES (r, 2, SYSDATE + ((SYSTIMESTAMP - v_ts) * 86400) - SYSDATE); END LOOP; FOR rec IN ( SELECT run, stmt, CAST(elapsed / MIN(elapsed) OVER() AS NUMBER(10, 5)) ratio FROM results ) LOOP dbms_output.put_line('Run ' || rec.run || ', Statement ' || rec.stmt || ' : ' || rec.ratio); END LOOP; END; / DROP TABLE results;

Yielding:

Run 1, Statement 1 : 9.19943 Run 1, Statement 2 : 1.07469 Run 2, Statement 1 : 12.86258 Run 2, Statement 2 : 1.03741 Run 3, Statement 1 : 13.80538 Run 3, Statement 2 : 1.09832 Run 4, Statement 1 : 13.91985 Run 4, Statement 2 : 1.16206 Run 5, Statement 1 : 9.37335 Run 5, Statement 2 : 1

Results are relative to each other (according to my understanding, I’m doing this in order to comply with the Oracle license regarding the publication of benchmark results – no actual times are published). Statement 2 (using explicit ranking) is repeatedly at least 9x faster than statement 1 (using FETCH ) on Oracle 12.2.0.1.0! Bummer!

What about SQL Server 2014?

Benchmarking logic:

DECLARE @ts DATETIME; DECLARE @repeat INT = 100; DECLARE @r INT; DECLARE @i INT; DECLARE @dummy1 INT; DECLARE @dummy2 VARCHAR; DECLARE @dummy3 VARCHAR; DECLARE @dummy4 VARCHAR; DECLARE @s1 CURSOR; DECLARE @s2 CURSOR; DECLARE @results TABLE ( run INT, stmt INT, elapsed DECIMAL ); SET @r = 0; WHILE @r < 5 BEGIN SET @r = @r + 1 SET @s1 = CURSOR FOR SELECT actor_id, first_name, last_name, title FROM actor a OUTER APPLY ( SELECT TOP 3 WITH TIES title FROM film f JOIN film_actor fa ON f.film_id = fa.film_id WHERE fa.actor_id = a.actor_id ORDER BY len(title) DESC ) t ORDER BY actor_id, len(title) DESC; SET @s2 = CURSOR FOR SELECT a.actor_id, first_name, last_name, title FROM actor a LEFT JOIN ( SELECT actor_id, title, rank() OVER ( PARTITION BY actor_id ORDER BY len(title) DESC ) rk FROM film f JOIN film_actor fa ON f.film_id = fa.film_id ) t ON a.actor_id = t.actor_id WHERE rk <= 3 ORDER BY a.actor_id, rk SET @ts = current_timestamp; SET @i = 0; WHILE @i < @repeat BEGIN SET @i = @i + 1 OPEN @s1; FETCH NEXT FROM @s1 INTO @dummy1, @dummy2, @dummy3, @dummy4; WHILE @@FETCH_STATUS = 0 BEGIN FETCH NEXT FROM @s1 INTO @dummy1, @dummy2, @dummy3, @dummy4; END; CLOSE @s1; END; DEALLOCATE @s1; INSERT INTO @results VALUES (@r, 1, DATEDIFF(ms, @ts, current_timestamp)); SET @ts = current_timestamp; SET @i = 0; WHILE @i < @repeat BEGIN SET @i = @i + 1 OPEN @s2; FETCH NEXT FROM @s2 INTO @dummy1, @dummy2, @dummy3, @dummy4; WHILE @@FETCH_STATUS = 0 BEGIN FETCH NEXT FROM @s2 INTO @dummy1, @dummy2, @dummy3, @dummy4; END; CLOSE @s2; END; DEALLOCATE @s2; INSERT INTO @results VALUES (@r, 2, DATEDIFF(ms, @ts, current_timestamp)); END; SELECT 'Run ' + CAST(run AS VARCHAR) + ', Statement ' + CAST(stmt AS VARCHAR) + ': ' + CAST(CAST(elapsed / MIN(elapsed) OVER() AS DECIMAL(10, 5)) AS VARCHAR) FROM @results;

Surprisingly, also SQL Server has worse performance with the APPLY approach than with window functions, although the difference is smaller (I’m using SQL Server 2014):

Run 1, Statement 1: 5.07019 Run 1, Statement 2: 1.11988 Run 2, Statement 1: 5.48820 Run 2, Statement 2: 1.20683 Run 3, Statement 1: 5.08882 Run 3, Statement 2: 1.31429 Run 4, Statement 1: 5.31863 Run 4, Statement 2: 1.00000 Run 5, Statement 1: 5.07453 Run 5, Statement 2: 1.21491

I would have expected SQL Server to be much better here, given that this syntax has been available for a long time in SQL Server. Challenge to the reader: Why do both databases perform so poorly with CROSS APPLY ? I’ll explain Oracle’s problem in a bit.

Let’s look at PostgreSQL 9.6

The “WITH TIES” semantics can only be implemented with window functions in PostgreSQL, so let’s stick to the “ROWS” semantics of the LIMIT clause.

DO $$ DECLARE v_ts TIMESTAMP; v_repeat CONSTANT INT := 100; rec RECORD; run INT[]; stmt INT[]; elapsed DECIMAL[]; max_elapsed DECIMAL; i INT := 1; BEGIN -- Repeat benchmark several times to avoid warmup penalty FOR r IN 1..5 LOOP v_ts := clock_timestamp(); FOR i IN 1..v_repeat LOOP FOR rec IN ( SELECT actor_id, first_name, last_name, title FROM actor a LEFT JOIN LATERAL ( SELECT title FROM film f JOIN film_actor fa USING (film_id) WHERE fa.actor_id = a.actor_id ORDER BY length(title) DESC LIMIT 3 ) t ON true ORDER BY actor_id, length(title) DESC ) LOOP NULL; END LOOP; END LOOP; run[i] := r; stmt[i] := 1; elapsed[i] := (EXTRACT(EPOCH FROM CAST(clock_timestamp() AS TIMESTAMP)) - EXTRACT(EPOCH FROM v_ts)); i := i + 1; v_ts := clock_timestamp(); FOR i IN 1..v_repeat LOOP FOR rec IN ( SELECT actor_id, first_name, last_name, title FROM actor a LEFT JOIN ( SELECT actor_id, title, row_number() OVER ( PARTITION BY actor_id ORDER BY length(title) DESC ) rn FROM film f JOIN film_actor fa USING (film_id) ) t USING (actor_id) WHERE rn <= 3 ORDER BY actor_id, rn ) LOOP NULL; END LOOP; END LOOP; run[i] := r; stmt[i] := 2; elapsed[i] := (EXTRACT(EPOCH FROM CAST(clock_timestamp() AS TIMESTAMP)) - EXTRACT(EPOCH FROM v_ts)); i := i + 1; END LOOP; SELECT max(t.elapsed) INTO max_elapsed FROM unnest(elapsed) AS t(elapsed); FOR i IN 1..array_length(run, 1) LOOP RAISE INFO 'RUN %, Statement %: %', run[i], stmt[i], CAST(elapsed[i] / max_elapsed AS DECIMAL(10, 5)); END LOOP; END$$;

This time, the results are more even. It doesn’t really seem to matter which approach you pick:

00000: RUN 1, Statement 1: 0.75904 00000: RUN 1, Statement 2: 0.99784 00000: RUN 2, Statement 1: 0.75907 00000: RUN 2, Statement 2: 0.95206 00000: RUN 3, Statement 1: 0.82102 00000: RUN 3, Statement 2: 0.94841 00000: RUN 4, Statement 1: 0.96208 00000: RUN 4, Statement 2: 0.96218 00000: RUN 5, Statement 1: 0.83398 00000: RUN 5, Statement 2: 1.00000

This can have two reasons:

Optimistic: LATERAL is better optimised in PostgreSQL

is better optimised in PostgreSQL Pessimistic: window functions are less well optimised in PostgreSQL

From experience with tuning PostgreSQL queries, I’ll go for the pessimistic guess, because indeed, there is no hint in the execution plan about any optimisation being done on the window function predicate:

Sort (cost=911.26..915.81 rows=1821 width=40) Sort Key: a.actor_id, t.rn -> Hash Join (cost=596.44..812.65 rows=1821 width=40) Hash Cond: (t.actor_id = a.actor_id) -> Subquery Scan on t (cost=589.94..781.11 rows=1821 width=25) Filter: (t.rn <= 3) -> WindowAgg (cost=589.94..712.83 rows=5462 width=29) -> Sort (cost=589.94..603.59 rows=5462 width=21) Sort Key: fa.actor_id, (length((f.title)::text)) DESC -> Hash Join (cost=77.50..250.88 rows=5462 width=21) Hash Cond: (fa.film_id = f.film_id) -> Seq Scan on film_actor fa (cost=0.0..84.62 rows=5462 width=4) -> Hash (cost=65.00..65.00 rows=1000 width=19) -> Seq Scan on film f (cost=0.00..65.00 rows=1000 width=19) -> Hash (cost=4.00..4.00 rows=200 width=17) -> Seq Scan on actor a (cost=0.00..4.00 rows=200 width=17)

What about DB2 10.5?

BEGIN DECLARE CONTINUE HANDLER FOR SQLSTATE '42710' BEGIN END; EXECUTE IMMEDIATE 'CREATE TABLE print_relative (run INTEGER, stmt INTEGER, elapsed DECIMAL(20, 4))'; END BEGIN DECLARE v_ts TIMESTAMP; DECLARE v_repeat INTEGER DEFAULT 100; DECLARE v_i INTEGER; DECLARE v_j INTEGER; -- Repeat benchmark several times to avoid warmup penalty SET v_i = 1; DELETE FROM print_relative; REPEAT SET v_j = 1; SET v_ts = CURRENT_TIMESTAMP; REPEAT FOR rec AS cur CURSOR FOR SELECT actor_id, first_name, last_name, title FROM actor a LEFT JOIN LATERAL ( SELECT title FROM film f JOIN film_actor fa ON f.film_id = fa.film_id WHERE fa.actor_id = a.actor_id ORDER BY length(title) DESC FETCH FIRST 3 ROWS ONLY ) t ON 1 = 1 ORDER BY actor_id, length(title) DESC DO BEGIN END; END FOR; SET v_j = v_j + 1; UNTIL v_j = v_repeat END REPEAT; INSERT INTO print_relative VALUES (v_i, 1, (CURRENT_TIMESTAMP - v_ts)); SET v_j = 1; SET v_ts = CURRENT_TIMESTAMP; REPEAT FOR rec AS cur CURSOR FOR SELECT a.actor_id, first_name, last_name, title FROM actor a LEFT JOIN ( SELECT actor_id, title, row_number() OVER ( PARTITION BY actor_id ORDER BY length(title) DESC ) rn FROM film f JOIN film_actor fa ON f.film_id = fa.film_id ) t ON t.actor_id = a.actor_id WHERE rn <= 3 ORDER BY a.actor_id, rn DO BEGIN END; END FOR; SET v_j = v_j + 1; UNTIL v_j = v_repeat END REPEAT; INSERT INTO print_relative VALUES (v_i, 2, (CURRENT_TIMESTAMP - v_ts)); SET v_i = v_i + 1; UNTIL v_i = 5 END REPEAT; END SELECT run, stmt, CAST(elapsed / MIN(elapsed) OVER() AS DECIMAL(20, 4)) ratio FROM print_relative; DROP TABLE print_relative;

Result, again, window functions outperform LATERAL joining:

1 1 6.0353 1 2 1.0783 2 1 6.2549 2 2 1.0093 3 1 6.1067 3 2 1.0000 4 1 6.1987 4 2 1.0128

Explanation for benchmarks

For this explanation, I’m looking at Oracle execution plans only – showing what kind of rationale could be derived from their plans. I’m assuming that similar problems may appear in the other 3 databases.

Here’s the plan for the window function query:

The following is the execution plan obtained through

SELECT * FROM TABLE ( dbms_xplan.display_cursor(format => 'ALLSTATS LAST') )

It displays the actual execution plan with gathered statistics (using the /*+GATHER_PLAN_STATISTICS*/ hint), not the estimated plan.

---------------------------------------------------------------------- | Id | Operation | Name | Starts | A-Rows | ---------------------------------------------------------------------- | 0 | SELECT STATEMENT | | 1 | 767 | | 1 | SORT ORDER BY | | 1 | 767 | |* 2 | HASH JOIN | | 1 | 767 | | 3 | TABLE ACCESS FULL | ACTOR | 1 | 200 | |* 4 | VIEW | | 1 | 767 | |* 5 | WINDOW SORT PUSHED RANK| | 1 | 1079 | |* 6 | HASH JOIN | | 1 | 5463 | | 7 | TABLE ACCESS FULL | FILM | 1 | 1000 | | 8 | INDEX FAST FULL SCAN | PK_FILM_ACTOR | 1 | 5463 | ---------------------------------------------------------------------- Predicate Information (identified by operation id): --------------------------------------------------- 2 - access("A"."ACTOR_ID"="T"."ACTOR_ID") 4 - filter("T"."RK"<=3) 5 - filter(RANK() OVER ( PARTITION BY "FA"."ACTOR_ID" ORDER BY LENGTH("F"."TITLE") DESC )<=3) 6 - access("F"."FILM_ID"="FA"."FILM_ID")

As can be seen, there’s some initial work of calculating the rank for most FILM_ACTOR rows from the nested query. I say most because the PUSHED RANK in the WINDOW SORT PUSHED RANK operation seems to indicate that the rank is calculated only as long as it is needed, i.e. until the ranking predicate rk <= 3 is no longer true.

In particular, this also means that we do not necessarily execute the entire sort, but we can keep a buffer of the current TOP 3, which can be done in O(N) rather than the full sort’s O(N log N) . Whether this is really done in Oracle, I don’t know.

In any case, the result of the ranking operation is then efficiently hash joined with the ACTOR table.

What’s most interesting, though, is the “Starts” column, which indicates how many times an operation has been started. Each operation is started only once!

What about APPLY ?

The plan is much worse:

--------------------------------------------------------------------------- | Id | Operation | Name | Starts | A-Rows | --------------------------------------------------------------------------- | 0 | SELECT STATEMENT | | 1 | 767 | | 1 | SORT ORDER BY | | 1 | 767 | | 2 | MERGE JOIN OUTER | | 1 | 767 | | 3 | TABLE ACCESS FULL | ACTOR | 1 | 200 | | 4 | BUFFER SORT | | 200 | 767 | | 5 | VIEW | VW_LAT_14BC7596 | 200 | 767 | | 6 | VIEW | VW_LAT_A18161FF | 200 | 767 | |* 7 | VIEW | | 200 | 767 | |* 8 | WINDOW SORT PUSHED RANK| | 200 | 1079 | | 9 | NESTED LOOPS | | 200 | 5463 | | 10 | TABLE ACCESS FULL | FILM | 200 | 200K| |* 11 | INDEX UNIQUE SCAN | PK_FILM_ACTOR | 200K| 5463 | --------------------------------------------------------------------------- Predicate Information (identified by operation id): --------------------------------------------------- 7 - filter("from$_subquery$_008"."rowlimit_$$_rank"<=3) 8 - filter(RANK() OVER ( ORDER BY LENGTH("F"."TITLE") DESC )<=3) 11 - access("FA"."ACTOR_ID"="A"."ACTOR_ID" AND "F"."FILM_ID"="FA"."FILM_ID")

Observe the “Starts” column. On Id 4, we’re experiencing 200 executions of the BUFFER SORT operation. That’s because there are exactly 200 rows in the ACTOR table (as can be seen on Id 3). So, indeed, there’s no optimisation happening that would calculate the “laterally correlated subquery” for all actors in one go.

Furthermore, quite unfortunately, the JOIN order between FILM and FILM_ACTOR seems quite wrong. For each of the 200 actors, we’re loading the entire 1000 films, which produces 200 * 1000 = 200K rows to scan for those belonging to the single actor from the outer query (Id 11). That’s unreasonably repetitive.

I’d expect Oracle to inverse the table accesses. We can do this with a hint:

SELECT actor_id, first_name, last_name, title FROM actor a OUTER APPLY ( SELECT /*+LEADING(fa f) USE_NL(fa f)*/ title FROM film f JOIN film_actor fa USING (film_id) WHERE actor_id = a.actor_id ORDER BY length(title) DESC FETCH FIRST 3 ROWS WITH TIES ) t ORDER BY actor_id, length(title) DESC;

Observe the two hints:

LEADING to indicate that the FILM_ACTOR table should be the driving row source of the join

to indicate that the table should be the driving row source of the join USE_NL to enforce a NESTED LOOP JOIN (without this, and with LEADING , Oracle would prefer a HASH JOIN)

With the hints, the benchmark results look a lot better:

Statement 1 : 1 (window functions) Statement 2 : 9.17483 (APPLY without hints) Statement 3 : 4.88774 (APPLY with LEADING hint) Statement 4 : 1.65269 (APPLY with LEADING and USE_NL hints)

Also, the execution plan now no longer has those excessive “Starts” and “A-Rows” values:

-------------------------------------------------------------------------------- | Id | Operation | Name | Starts | A-Rows | -------------------------------------------------------------------------------- | 0 | SELECT STATEMENT | | 1 | 50 | | 1 | SORT ORDER BY | | 1 | 50 | | 2 | MERGE JOIN OUTER | | 1 | 767 | | 3 | TABLE ACCESS FULL | ACTOR | 1 | 200 | | 4 | BUFFER SORT | | 200 | 767 | | 5 | VIEW | VW_LAT_2880E7C5| 200 | 767 | | 6 | VIEW | VW_LAT_A18161FF| 200 | 767 | |* 7 | VIEW | | 200 | 767 | |* 8 | WINDOW SORT PUSHED RANK | | 200 | 1079 | | 9 | NESTED LOOPS | | 200 | 5463 | | 10 | NESTED LOOPS | | 200 | 5463 | |* 11 | INDEX RANGE SCAN | PK_FILM_ACTOR | 200 | 5463 | |* 12 | INDEX UNIQUE SCAN | PK_FILM | 5463 | 5463 | | 13 | TABLE ACCESS BY INDEX ROWID| FILM | 5463 | 5463 | -------------------------------------------------------------------------------- Predicate Information (identified by operation id): --------------------------------------------------- 7 - filter("from$_subquery$_006"."rowlimit_$$_rank"<=3) 8 - filter(RANK() OVER ( ORDER BY LENGTH("F"."TITLE") DESC )<=3) 11 - access("FA"."ACTOR_ID"="A"."ACTOR_ID") 12 - access("F"."FILM_ID"="FA"."FILM_ID")

The highest number of “A-Rows” is easily explained. There are exactly 5463 rows in FILM_ACTOR . Still, I’d wish that the 200 TOP-N lookups per actor could be optimised somehow.

Interestingly, this plan is associated with a much higher cost than the original one, even if in this case, it is much better.

Conclusion

TOP N queries are very common, we need them all the time. The simplest way is to use an ORDER BY clause and a LIMIT clause.

Sometimes, we need WITH TIES semantics, and Oracle 12c as well as SQL Server can provide this with standard or vendor specific syntax, out of the box. Other databases can emulate WITH TIES using window functions rather easily.