Why query with ORDER BY RANDOM() much slower than CTE version?
(1.1) By Keyka Vigiliant (kekavigi) on 2025-07-20 16:46:57 edited from 1.0 [source]
Hello, I am a SQL beginner, and I would like to ask why the following two SQL commands have much different execution times. Initially, I have the following database tables
PRAGMA journal_mode = wal;
PRAGMA synchronous = off;
PRAGMA temp_store = memory;
PRAGMA mmap_size = 30000000000;
PRAGMA busy_timeout = 10000;
PRAGMA wal_autocheckpoint;
CREATE TABLE IF NOT EXISTS board(
fen BLOB NOT NULL, -- compressed chess position
depth INTEGER NOT NULL, -- analysis depth
score INTEGER NOT NULL, -- in centipawn
move INTEGER, -- best move, if exist
PRIMARY KEY (fen)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS ix_covering
ON board (depth, score);
Then, I want to search 10 rows on the board randomly, with depth=35 and score>=100. I thought of the following SQL command
SELECT fen, score, depth, move
FROM board
WHERE depth=35 AND score >= 100
ORDER BY RANDOM()
LIMIT 10
The command took about 80 seconds to execute. But it also occurred to me that the command could also be written as
WITH eligible AS (
SELECT fen FROM board
WHERE depth=35 AND score >= 100
ORDER BY RANDOM()
LIMIT 10
)
SELECT fen, score, depth, move
FROM board
WHERE fen IN eligible
which only takes about 0.1 - 0.2 seconds. I have run ANALYZE, but the results are still the same. The difference in execution time is confusing, because I thought the two commands were doing the same thing. Why can't the first version of SELECT be as fast as the second version, even though the depth and score used in the WHERE condition are indexed?
I am using SQLite 3.47.2; there are about 701.5M rows in the board table, and here is a sample row in the table (displayed using Python):
{'fen': b'\xa3\xf1\x00g@\x00\x18`\x10\x10\x80\x00',
'score': 4083,
'depth': 35,
'move': 1800}
(2) By SeverKetor on 2025-07-17 18:58:56 in reply to 1.0 [link] [source]
Start by looking at the query plans (in the CLI, add EXPLAIN QUERY PLAN before your query and it'll give you a summary of what it'll do). You can post the results here too so people have more info to go on
(3) By Keyka Vigiliant (kekavigi) on 2025-07-17 23:38:31 in reply to 2 [link] [source]
Okay, here is the EXPLAIN QUERY PLAN result for the first version of SELECT:
QUERY PLAN
|--SEARCH board USING INDEX ix_covering (depth=? AND score>?)
`--USE TEMP B-TREE FOR ORDER BY
While the following is for SELECT with CTE:
QUERY PLAN
|--SEARCH board USING PRIMARY KEY (fen=?)
`--LIST SUBQUERY 2
|--SEARCH board USING COVERING INDEX ix_covering (depth=? AND score>?)
`--USE TEMP B-TREE FOR ORDER BY
I'm still unable to interpret why the CTE version is faster, even though it contains the same sub-plan(?) as the simple (first) version.
(4.1) By DrkShadow (drkshadow) on 2025-07-18 01:36:58 edited from 4.0 in reply to 1.0 [link] [source]
This is probably the optimization use-case: it doesn't need to read the table data if all of the data is in the index, and your WITHOUT ROWID probably makes this happen.
Disclaimer: I'm mostly guessing. I don't know.
Consider the CTE expression,
CREATE TABLE ... PRIMARY KEY (fen) WITHOUT ROWID
CREATE INDEX ... ON board (depth, score);
The CTE grabs things according to the depth and score - which you've indexed. It can use those to get the indexed row of the table -- which you've defined to be fen -- and order by fen. Then choose ten.
All of this can come from the one index that you've created, sqlite just pounces over the value-range of that index and sums together the rows that match your where clause. Yay. Put these in a list, order, and choose ten.
With those ten, do a table-lookup to get all four values: fen, score, depth, and move.
Compare instead the full query,
SELECT fen, score, depth, move
FROM board
WHERE <indexed>
ORDER BY..
Now you have to select move - which isn't in any index. You have to read the full row from the table, in a random-read fashion (if you ANALYZE the table it may give you a full table scan for speed) and this incurs your cost. It's a random table scan (SEARCH board USING PRIMARY KEY), and if the number of matches for the score-range are more than 25% of the table, it'll be slower than a full table scan. It does this so that it can read all requested values - scores, depth, fen, AND move, for all matching (scores, depth), AND THEN and only after reading all this data, from the table data on disk, selecting ten rows at random.
It's kind of an interesting case. If you made the table WITH rowid I suspect you would see similar (slower) performance with both queries. (Partly: fen, being a blob instead of an INTEGER PRIMARY KEY, would not be the rowid -- so it wouldn't be in the index. The CTE version would have to read the row from disk - all rows matching the range, by rowid, just as the full query does.) Again: I don't know, I'm just guessing.
(5) By punkish on 2025-07-19 13:55:47 in reply to 4.1 [link] [source]
Disclaimer: I'm mostly guessing. I don't know.
For a guess (and, even compared to most not-a-guess replies), this is one of the best darn explanations I've read and learned a few things for my own future table design. Thanks.
(7.1) By Keyka Vigiliant (kekavigi) on 2025-07-20 11:53:42 edited from 7.0 in reply to 4.1 [link] [source]
I got curious about your guess, and created an experiment to try it out; but first, I need to delete all rows with depth<20 as I don't have much storage left. Then I created the following new database
PRAGMA journal_mode = wal;
PRAGMA synchronous = off;
PRAGMA temp_store = memory;
PRAGMA mmap_size = 30000000000;
PRAGMA busy_timeout = 10000;
PRAGMA wal_autocheckpoint;
CREATE TABLE board(
fen BLOB NOT NULL,
depth INTEGER NOT NULL,
score INTEGER NOT NULL,
move INTEGER
);
ATTACH DATABASE 'original.sqlite' as orig;
INSERT INTO board (fen, depth, score, move)
SELECT fen, depth, score, move
FROM orig.board WHERE TRUE;
CREATE INDEX ix_covering ON board (depth, score);
After running ANALYZE, I tried to measure the execution time of full-query(?) SELECT, and SELECT using CTE, in both databases. Here are the results (time in seconds):
N=10
original db, full query mean:82.298 std:5.392
original db, using cte mean:0.156 std:0.005
db with rowid, full query mean:0.215 std:0.011
db with rowid, using cte mean:71.169 std:2.150
From these results it seems that the full query SELECT in the table with rowid is just as fast, as SELECT using CTE in the table without rowid.
The fast execution time for full-query SELECT on table with rowid makes sense to me. Reading the documentation pages Query Planning, I assume SQLite would perform a binary search(es?) on ix_covering to get a list of rowids with depth=35 AND score>100, then do a binary search on the board table of all rowids in that list, while extracting the fen, score, depth, move data.
My thinking is that SELECT using CTE on a table without rowid basically does the same thing (and this is supported by their similar execution times).
Query Planning ΒΆ WITHOUT ROWID tables also said "The basic principals described above apply to both ordinary rowid tables and WITHOUT ROWID tables. The only difference is that the rowid column that serves as the key for tables and that appears as the right-most term in indexes is replaced by the PRIMARY KEY." This makes sense because the EXPLAIN QUERY PLAN result for the full query SELECT on the table with rowid
QUERY PLAN
|--SEARCH board USING INDEX ix_covering (depth=? AND score>?)
`--USE TEMP B-TREE FOR ORDER BY
is same as the plan of the full query SELECT on the table without rowid (see post 99658a5440d6815e). But the fact that execution times of full-query SELECT on rowid vs without rowid are so different makes me confused.
(8.2) By DrkShadow (drkshadow) on 2025-07-20 15:20:09 edited from 8.1 in reply to 7.1 [link] [source]
Very nice. Admittedly, I don't understand all of it, but I did just notice one thing..
QUERY PLAN
|--SEARCH board USING INDEX ix_covering (depth=? AND score>?)
`--USE TEMP B-TREE FOR ORDER BY
is the "full" select, and indexed by lacking the CTE expression, and
|--SEARCH board USING PRIMARY KEY (fen=?)
`--LIST SUBQUERY 2
|--SEARCH board USING COVERING INDEX ix_covering (depth=? AND score>?)
`--USE TEMP B-TREE FOR ORDER BY
is the CTE plan.
The difference here is: ix_covering. In the full select, it is, in fact, NOT a COVERING expression. However, in the CTE it is. This means that my theory is half right: it's getting all the information that it needs from the index, pulling together all of those values, sorting, taking the top-10, and then pulling full rows from the table-data (disk) to get fen and move.
In the full-select, it's doing the same, except without a covering index: read the index for matches, read the table-data(disk) data for *all of those rows, order all of those, and take the top-10.
I'm unsure how fen or the row-id works into this. I'm unsure why, when selecting fen, you get a covering index, when fen isn't in the index. (But fen is the rowid in the WITHOUT ROWID table, so it should be returned by the index .... but only if it's a without rowid table? which is why I don't understand your test. I don't doubt it, I just don't understand it.)
The output from EXPLAIN gives byte-code for the queries, and they're small so it shouldn't be too hard to parse, to see what it's doing differently (everything) or where it stops early in the CTE case, or where it gets that ROWID from in the WITH ROWID case. It would be interesting, but I'm personally not good with the byte-code, so I'd have to let someone else comment there.
Ohh I see, rather than making the full select fast with/without rowid (you can't -- it needs the move column, and it always fetches all matching rows, and it can't use a covering index for that), the CTE becomes slow WITH ROWID:
CREATE TABLE board(
fen BLOB NOT NULL,
);
CREATE INDEX..
WITH eligible AS (
SELECT fen FROM board
WHERE depth=35 AND score >= 100
ORDER BY RANDOM()
LIMIT 10
)
SELECT fen, score, depth, move
FROM board
WHERE fen IN eligible
---
-- I expect this query to now be slow, because fen is no longer part of the index,
-- and you will not get a COVERING INDEX in the query plan
You might also JOIN eligible ON board.fen=eligible.fen and see what happens, but I suspect it won't change anything.
(9) By Keyka Vigiliant (kekavigi) on 2025-07-20 16:20:05 in reply to 8.2 [link] [source]
Ah wow, thanks, I just realized the COVERING expression only appears in the query plan of SELECT using CTE. Thanks also for the theory you wrote, because it makes me curious and want to experiment.
Here are the results of EXPLAIN for both versions of SELECT, in each database:
original db (without rowid), full query select
addr opcode p1 p2 p3 p4 p5 comment
---- ------------- ---- ---- ---- ------------- -- -------------
0 Init 0 32 0 0 Start at 32
1 OpenEphemeral 1 6 0 k(1,B) 0 nColumn=6
2 Integer 10 1 0 0 r[1]=10; LIMIT counter
3 OpenRead 0 2 0 k(1,) 0 root=2 iDb=0; board
4 OpenRead 2 5161525 0 k(3,,,) 0 root=5161525 iDb=0; ix_covering
5 Integer 35 2 0 0 r[2]=35
6 Integer 100 3 0 0 r[3]=100
7 SeekGE 2 24 2 2 0 key=r[2..3]
8 IdxGT 2 24 2 1 0 key=r[2]
9 Column 2 2 4 0 r[4]= cursor 2 column 2
10 NotFound 0 23 4 1 0 key=r[4]
11 Function 0 0 5 random(0) 0 r[5]=func()
12 Sequence 1 6 0 0 r[6]=cursor[1].ctr++
13 IfNotZero 1 17 0 0 if r[1]!=0 then r[1]--, goto 17
14 Last 1 0 0 0
15 IdxLE 1 23 5 1 0 key=r[5]
16 Delete 1 0 0 0
17 Column 2 2 7 0 r[7]= cursor 2 column 2
18 Column 2 1 8 0 r[8]= cursor 2 column 1
19 Column 2 0 9 0 r[9]= cursor 2 column 0
20 Column 0 3 10 0 r[10]= cursor 0 column 3
21 MakeRecord 5 6 11 0 r[11]=mkrec(r[5..10])
22 IdxInsert 1 11 5 6 0 key=r[11]
23 Next 2 8 0 0
24 Sort 1 31 0 0
25 Column 1 5 10 0 r[10]=move
26 Column 1 4 9 0 r[9]=depth
27 Column 1 3 8 0 r[8]=score
28 Column 1 2 7 0 r[7]=fen
29 ResultRow 7 4 0 0 output=r[7..10]
30 Next 1 25 0 0
31 Halt 0 0 0 0
32 Transaction 0 0 8 0 1 usesStmtJournal=0
33 Goto 0 1 0 0
original db (without rowid), select using CTE
addr opcode p1 p2 p3 p4 p5 comment
---- ------------- ---- ---- ---- ------------- -- -------------
0 Init 0 42 0 0 Start at 42
1 OpenRead 3 2 0 k(1,) 2 root=2 iDb=0; sqlite_autoindex_board_1
2 BeginSubrtn 0 2 0 subrtnsig:2,A 0 r[2]=NULL
3 Once 0 29 0 0
4 OpenEphemeral 4 1 0 k(1,B) 0 nColumn=1; Result of SELECT 2
5 Noop 10000 3 0 0 Bloom filter
6 OpenEphemeral 5 3 0 k(1,B) 0 nColumn=3
7 Integer 10 4 0 0 r[4]=10; LIMIT counter
8 OpenRead 6 5161525 0 k(3,,,) 0 root=5161525 iDb=0; ix_covering
9 Integer 35 5 0 0 r[5]=35
10 Integer 100 6 0 0 r[6]=100
11 SeekGE 6 23 5 2 0 key=r[5..6]
12 IdxGT 6 23 5 1 0 key=r[5]
13 Function 0 0 7 random(0) 0 r[7]=func()
14 Sequence 5 8 0 0 r[8]=cursor[5].ctr++
15 IfNotZero 4 19 0 0 if r[4]!=0 then r[4]--, goto 19
16 Last 5 0 0 0
17 IdxLE 5 22 7 1 0 key=r[7]
18 Delete 5 0 0 0
19 Column 6 2 9 0 r[9]= cursor 6 column 2
20 MakeRecord 7 3 10 0 r[10]=mkrec(r[7..9])
21 IdxInsert 5 10 7 3 0 key=r[10]
22 Next 6 12 0 0
23 Sort 5 28 0 0
24 Column 5 2 12 0 r[12]=fen
25 MakeRecord 12 1 11 A 0 r[11]=mkrec(r[12])
26 IdxInsert 4 11 12 1 0 key=r[11]
27 Next 5 24 0 0
28 NullRow 4 0 0 0
29 Return 2 3 1 0
30 Rewind 4 41 0 0
31 Column 4 0 1 0 r[1]= cursor 4 column 0
32 IsNull 1 40 0 0 if r[1]==NULL goto 40
33 SeekGE 3 40 1 1 0 key=r[1]
34 IdxGT 3 40 1 1 0 key=r[1]
35 Column 3 0 13 0 r[13]= cursor 3 column 0
36 Column 3 2 14 0 r[14]= cursor 3 column 2
37 Column 3 1 15 0 r[15]= cursor 3 column 1
38 Column 3 3 16 0 r[16]= cursor 3 column 3
39 ResultRow 13 4 0 0 output=r[13..16]
40 Next 4 31 0 0
41 Halt 0 0 0 0
42 Transaction 0 0 8 0 1 usesStmtJournal=0
43 Goto 0 1 0 0
experiment db (with rowid), full query select
addr opcode p1 p2 p3 p4 p5 comment
---- ------------- ---- ---- ---- ------------- -- -------------
0 Init 0 31 0 0 Start at 31
1 OpenEphemeral 1 6 0 k(1,B) 0 nColumn=6
2 Integer 10 1 0 0 r[1]=10; LIMIT counter
3 OpenRead 0 2 0 4 0 root=2 iDb=0; board
4 OpenRead 2 5972116 0 k(3,,,) 0 root=5972116 iDb=0; ix_covering
5 Integer 35 2 0 0 r[2]=35
6 Integer 100 3 0 0 r[3]=100
7 SeekGE 2 23 2 2 0 key=r[2..3]
8 IdxGT 2 23 2 1 0 key=r[2]
9 DeferredSeek 2 0 0 0 Move 0 to 2.rowid if needed
10 Function 0 0 4 random(0) 0 r[4]=func()
11 Sequence 1 5 0 0 r[5]=cursor[1].ctr++
12 IfNotZero 1 16 0 0 if r[1]!=0 then r[1]--, goto 16
13 Last 1 0 0 0
14 IdxLE 1 22 4 1 0 key=r[4]
15 Delete 1 0 0 0
16 Column 0 0 6 0 r[6]= cursor 0 column 0
17 Column 2 1 7 0 r[7]= cursor 2 column 1
18 Column 2 0 8 0 r[8]= cursor 2 column 0
19 Column 0 3 9 0 r[9]= cursor 0 column 3
20 MakeRecord 4 6 10 0 r[10]=mkrec(r[4..9])
21 IdxInsert 1 10 4 6 0 key=r[10]
22 Next 2 8 0 0
23 Sort 1 30 0 0
24 Column 1 5 9 0 r[9]=move
25 Column 1 4 8 0 r[8]=depth
26 Column 1 3 7 0 r[7]=score
27 Column 1 2 6 0 r[6]=fen
28 ResultRow 6 4 0 0 output=r[6..9]
29 Next 1 24 0 0
30 Halt 0 0 0 0
31 Transaction 0 0 3 0 1 usesStmtJournal=0
32 Goto 0 1 0 0
experiment db (with rowid), select using CTE
(This query doesn't seem to be relevant to my question, but I included it for the sake of completeness.)
addr opcode p1 p2 p3 p4 p5 comment
---- ------------- ---- ---- ---- ------------- -- -------------
0 Init 0 44 0 0 Start at 44
1 OpenRead 0 2 0 4 0 root=2 iDb=0; board
2 Rewind 0 43 0 0
3 Noop 0 0 0 0 begin IN expr
4 BeginSubrtn 0 1 0 subrtnsig:2,A 0 r[1]=NULL
5 Once 0 33 0 0
6 OpenEphemeral 3 1 0 k(1,B) 0 nColumn=1; Result of SELECT 2
7 Noop 10000 2 0 0 Bloom filter
8 OpenEphemeral 4 3 0 k(1,B) 0 nColumn=3
9 Integer 10 3 0 0 r[3]=10; LIMIT counter
10 OpenRead 2 2 0 3 0 root=2 iDb=0; board
11 OpenRead 5 5972116 0 k(3,,,) 0 root=5972116 iDb=0; ix_covering
12 Integer 35 4 0 0 r[4]=35
13 Integer 100 5 0 0 r[5]=100
14 SeekGE 5 27 4 2 0 key=r[4..5]
15 IdxGT 5 27 4 1 0 key=r[4]
16 DeferredSeek 5 0 2 0 Move 2 to 5.rowid if needed
17 Function 0 0 6 random(0) 0 r[6]=func()
18 Sequence 4 7 0 0 r[7]=cursor[4].ctr++
19 IfNotZero 3 23 0 0 if r[3]!=0 then r[3]--, goto 23
20 Last 4 0 0 0
21 IdxLE 4 26 6 1 0 key=r[6]
22 Delete 4 0 0 0
23 Column 2 0 8 0 r[8]= cursor 2 column 0
24 MakeRecord 6 3 9 0 r[9]=mkrec(r[6..8])
25 IdxInsert 4 9 6 3 0 key=r[9]
26 Next 5 15 0 0
27 Sort 4 32 0 0
28 Column 4 2 11 0 r[11]=fen
29 MakeRecord 11 1 10 A 0 r[10]=mkrec(r[11])
30 IdxInsert 3 10 11 1 0 key=r[10]
31 Next 4 28 0 0
32 NullRow 3 0 0 0
33 Return 1 5 1 0
34 Column 0 0 12 0 r[12]= cursor 0 column 0
35 Affinity 12 1 0 A 0 affinity(r[12])
36 NotFound 3 42 12 1 0 key=r[12]; end IN expr
37 Column 0 0 13 0 r[13]= cursor 0 column 0
38 Column 0 2 14 0 r[14]= cursor 0 column 2
39 Column 0 1 15 0 r[15]= cursor 0 column 1
40 Column 0 3 16 0 r[16]= cursor 0 column 3
41 ResultRow 13 4 0 0 output=r[13..16]
42 Next 0 3 0 1
43 Halt 0 0 0 0
44 Transaction 0 0 3 0 1 usesStmtJournal=0
45 Goto 0 1 0 0
(6) By Mike Castle (nexushoratio) on 2025-07-19 15:25:03 in reply to 1.0 [link] [source]
When I first saw just the subject, my initial thought was RANDOM() was being called each time rows were compared and confusing things. But after reading your code, I'm less sure.
What I have done for similar results is:
SELECT
RANDOM() as r, fen, score, depth, move
FROM board
WHERE depth = 35 AND score >= 1000
ORDER BY r
LIMIT 10
;
(10) By anonymous on 2025-07-21 07:25:11 in reply to 1.1 [link] [source]
May I ask a stupid question, please?
Do all these queries really generate 701.5M random numbers, sort them, and then select just 10?
Is it possible to generate 10 random numbers between 1 and 701.5M, and then use row_number() over() to select those 10 out of the "board" table, and not sort?
Would that be expected to be quicker? I tried an example on 2 million rows and I think it works but it takes 1-2 full seconds. But I know very little.
I realize you have to get each table row assocaited with a random number; but to a novice it seems odd to generate all those numbers and sort compared to generating ten and just numbering the rows sequentially without sorting.
Does it work differently in SQL somehow that makes it more efficient? Thank you.
(11) By Bo Lindbergh (_blgl_) on 2025-07-21 14:17:43 in reply to 10 [link] [source]
You would need to generate all the rows twice: once to count them (so you know what the range of your random numbers should be) and once again to select some of them.