17°
Portada del artículo: I tried a vectorized PostgreSQL 19 on a laptop: 18 to 21% faster, and a 16,000-value ANY 948 times slower
PostgreSQLDatabasesPerformanceBenchmark

I tried a vectorized PostgreSQL 19 on a laptop: 18 to 21% faster, and a 16,000-value ANY 948 times slower

I tried pg_vexec, a vectorized executor for PostgreSQL 19, on a 4-core laptop: ClickBench gets 18 to 21% faster and every answer passes the author's checker against DuckDB. But the rewrite that rescues the planner from a huge OR, switching to = ANY, makes it pick a scan 948 times slower.

Efrain Garay 9 October 2026

Playing summary

This week two PostgreSQL stories came out that had nothing to do with each other. On Habr, in Russian, pg_vexec appeared: a vectorized executor for PostgreSQL 19 that comes in as an extension, without forking the engine, and that its author measured with ClickBench on a 16-core Ryzen. On Zenn, in Japanese, an engineer described how a query with 16,000 OR conditions loaded their database before it even ran: 56 seconds just to plan.

I wanted to see both on my own machine, and a modest one: a Lenovo IdeaPad with a 4-core i5-8250U and 12 GB of RAM. Putting them together turned up a third thing neither article mentions.

Narrated: a vectorized executor for PostgreSQL 19 measured on a laptop. ClickBench improves by 18 to 21%, but a 16,000-value = ANY goes from 73 milliseconds to 69 seconds because the cost model counts it as a single comparison.Watch it in the reel viewer →

What pg_vexec is

PostgreSQL executes queries row by row: each plan node asks the one below for a row, processes it and hands it up. Analytical engines like DuckDB or ClickHouse work in batches: they take a thousand values of a column and apply the same operation to all of them at once, which is much kinder to the processor’s cache.

pg_vexec brings that idea to PostgreSQL 19 as a module loaded through shared_preload_libraries. It hooks into the planner and offers vectorized versions of scans, aggregations, joins and sorts that compete on cost with the regular ones. Whatever a vector kernel can’t compute falls back to PostgreSQL’s own evaluation, and vexec.mode decides: off is plain PostgreSQL, auto chooses by cost and force uses the vector version wherever it can.

How I set it up

The repository ships no images, so I built my own: PostgreSQL from the REL_19_STABLE branch (it reports itself as 19beta4), without assertions, and pg_vexec built with PGXS from its main branch. It compiled on the first try. For ClickBench I used the author’s own subset, every tenth row of the original file (9,999,750 rows, 6,866 MB per PostgreSQL), built with the author’s own script, and the settings ClickBench computes for this machine.

Two stumbles. The lenovo runs NixOS and had no Docker: I declared it in the system configuration before starting. And the first outside review of my numbers made me redo half the work: I had measured memory differently from the Japanese article, mixed times taken inside EXPLAIN ANALYZE with psql times, and always run the ClickBench modes in the same order on a laptop that heats up. All of that is fixed below.

ClickBench: it delivers

I ran the 43 queries in the three modes, two full passes in opposite orders, three tries per query with the system cache dropped before the first. The hot time is the best of the second and third tries, the way ClickBench computes it.

ClickBench, geometric mean of the 43 hot times (with ClickBench's 10 ms shift)ms · lower is better
  1. vexec off, pass 12,932 msplain PostgreSQL
  2. vexec auto, pass 12,313 ms21% less
  3. vexec off, pass 22,867 msplain PostgreSQL
  4. vexec auto, pass 22,335 ms18.5% less

Measured on 8 and 9 October 2026 on an i5-8250U with 12 GB, 9,999,750 rows, PostgreSQL 19 (REL_19_STABLE) and pg_vexec 634d868e85. Pass 1 in the order off, auto, force; pass 2 the other way round.

The author reported 19% on a 16-core Ryzen with 60 GB; on the laptop the gain lands between 18 and 21%. The biggest gains repeat in both passes and match the author’s: query 16 goes from 15.2 to 3.3 seconds, 29 gets 3.2 times faster and 15, 2.8. COUNT(DISTINCT) goes both ways: 10, 11 and 13 get slower in auto (0.60 to 0.64 times in the first pass, 0.52 to 0.55 in the second), 4 a little too, and 5 and 8 get faster; the author warns about the slow ones as well. force gives almost the same as auto. On the first tries of the first pass, right after the system cache is dropped, the three modes’ means land close (25.4 to 26.1 seconds).

What I cared about most was something else: that it wouldn’t change the answers. Every answer of both passes passes the author’s checker against DuckDB 1.5.5, with no errors. That checker is strict about order and ties, but for queries whose LIMIT cuts a run of tied rows it compares only the sort keys. Across the three modes, 32 of the 43 answers are byte-identical in the second pass (33 in the first); the rest are exactly the ones ClickBench leaves open: ties, a LIMIT without ORDER BY, and a sort on a column the query doesn’t return.

The huge OR, on five versions

The Japanese article uses an inventory table: 4 warehouses, 50,000 products and 4 receipt dates, 800,000 rows, with the primary key on the first three columns. The query asks for one day’s stock for a list of (warehouse, product) pairs, written as an OR of 16,000 conditions. I rebuilt it as is and ran it on PostgreSQL 15, 16, 17, 18 and 19.

Planning time grows with the square of the conditions on every version: 0.33 seconds for 1,000 pairs, 5.1 for 4,000, 21.6 for 8,000 and 90.5 for 16,000 on PostgreSQL 15; with 16,000 pairs, 16 takes 91.0, 17 takes 94.4, 18 takes 96.3 and 19, 87.6. Measured the way the article measured it, the process’s peak memory right after planning lands close: 22, 45 and 135 MiB against the 25, 48 and 140 MB it reports. No version changed that curve.

With 16,000 pairs the query didn’t even get to run in Docker: the parallel plan asks for a 73 to 82 MB shared memory segment and the container comes with 64 MB of /dev/shm by default. That’s a container trap, not a PostgreSQL one. With 1 GB, PostgreSQL 19 finishes it in 90.7 seconds, 87.3 of them planning.

The known way out is to rewrite the OR as an IN or an = ANY per warehouse: four conditions instead of 16,000. It plans in milliseconds and the whole query takes about 70 ms.

There’s another kind of OR that did change: the one that compares a single column with many values. PostgreSQL 18 started turning it into an ANY internally.

OR of 4,000 values on a single column, by versionms · lower is better
  1. PostgreSQL 1529.5 s
  2. PostgreSQL 1629.9 s
  3. PostgreSQL 1729.9 s
  4. PostgreSQL 1876 msthe OR becomes an ANY
  5. PostgreSQL 1939 ms

psql timing, median of 3, the same 800,000-row table and default settings, official PostgreSQL 15.19, 16.15, 17.11 and 18.6 images; 19 built from REL_19_STABLE.

And a small regression along the way: the 16,000-value = ANY on that column takes 65 to 69 ms on PostgreSQL 15 to 17 and 225 ms on 18, which chooses to walk the primary key skipping its first column instead of a parallel sequential scan. PostgreSQL 19 is back to 71 ms.

Where they meet: the ANY that fools the vectorizer

With both set up, the obvious question was what pg_vexec does with the recommended rewrite. The answer surprised me. I measured it with two queries. The first asks for any product in a list of 16,000, in any warehouse, product_id = ANY (...), and returns 64,000 rows: in auto mode it goes from 73 ms to 69.3 seconds, 948 times slower. The second is the article’s, the (warehouse, product) pairs, with its two rewrites, IN and = ANY per warehouse, which return 16,000 rows: they go from about 70 ms to 16.9 and 16.8 seconds.

What the planner compares for an = ANY of 16,000 valuesThree candidate plans over 800,000 rows with their estimated cost and their measured time. pg_vexec in auto mode prices its vector scan at 14,588, below the primary key's bitmap scan at 15,913, takes it and the query goes from 73 ms to 69.3 s.= ANY of 16,000 values800,000 rowsBitmap scan of the primary keyvexec off chooses it15,91373 msVec Seq Scanvexec auto chooses it: date and ANY together count as 2 kernel steps14,58869.3 sPlain sequential scannot chosen19,923not runestimated costmeasured
The cheapest estimate is not the fastest plan: 948 times slower. Costs from the saved plans, times from psql (median of 3).

The plan explains half of it. With pg_vexec off, PostgreSQL looks the values up in the primary key with a bitmap scan, estimated at 15,913. In auto, pg_vexec prices its vector sequential scan at 14,588, cheaper than that plan and than the plain sequential scan (19,923), and takes it.

The code explains the other half. To estimate, pg_vexec takes the cost PostgreSQL computes for that = ANY, and with a constant array of 9 or more values PostgreSQL costs it the way it would run it: build a hash table once, at a cost per value, then charge a single lookup per row. pg_vexec, however, doesn’t hash when it runs: it compares the batch with each value of the array, one at a time, while any row is still undecided. A row that reaches that condition and matches no value goes through all 16,000 comparisons, and in the first query that’s two rows in three: the list holds 16,000 of the 50,000 products. The cost assumes one lookup per row and the execution, for those rows, does 16,000.

vexec off: the primary key73 ms
a bitmap scan of the primary key
✓ done
vexec auto: Vec Seq Scan69.3 s
each batch of up to 1,024 rows against value 1, 2, 3 … 16,000
18,00016,000
schematic: 5 batches per loop of this animation, up to 782 if every batch were full

The funny part is that the first query written as an OR doesn’t fall for it: pg_vexec counts it as 16,001 steps, finds it expensive and keeps PostgreSQL’s index (189 ms against 185 with pg_vexec off). In other words, the rewrite that rescues the planner is the very one that fools the vectorizer’s estimator. And in force mode, with no choice, the OR goes to 31 seconds too.

What I cannot claim

It’s one machine, one subset and one setting per test. Two ClickBench passes aren’t enough for a confidence interval: the 18 to 21% range comes from those two. I didn’t record how many parallel workers actually launched in each query, only how many each mode planned (4 in all of them). The execution times I quote from EXPLAIN ANALYZE are instrumented; the comparisons that matter I repeated with psql’s own clock. And the reading of the code is mine: the report I prepared for the author presents it that way, with the lines cited.

My take

pg_vexec is serious work. It compiled without a fight, passed the ClickBench checker on every answer and repeated on a laptop the gain its author measured on a machine with four times the cores. That isn’t common in a project this new.

The problem I found is the mismatch between what it estimates and what it runs, and deciding when it’s worth it is the hardest part of an executor like this. The case doesn’t look exotic either: an application that turns a list of IDs into an = ANY with the values written into the query builds one of this shape. I didn’t test arrays passed as parameters or generic plans. The likely fix is to evaluate the ANY with a hash table, the way PostgreSQL does, or to charge it per element as long as it runs this way.

When I would use it and when not

  • Yes, for analytical queries over large tables with aggregations, which is where ClickBench shows the gain, knowing it’s very new software on a PostgreSQL that is still in beta.
  • Carefully, if your queries filter with = ANY or IN of hundreds or thousands of literal values: check the plan with EXPLAIN (VEXEC) and, if a Vec Seq Scan shows up where there used to be an index, leave that session at vexec.mode = off.
  • For the huge OR, on any version: don’t build thousands of pairs with OR. Group them by the first column with IN or = ANY, and if you run PostgreSQL in Docker, raise /dev/shm above the default 64 MB.

Frequently asked questions

What is pg_vexec?

An extension for PostgreSQL 19 that adds a vectorized planner and executor without forking PostgreSQL: it processes column batches of up to 1,024 rows instead of one row at a time, and competes on cost with the regular nodes. vexec.mode turns it off, auto or force.

How much faster is it on a laptop?

On ClickBench, over 10 million rows on a 4-core i5-8250U, the geometric mean of the hot times drops 18 to 21% in auto mode, depending on the order the modes ran in. Three queries with COUNT(DISTINCT), 10, 11 and 13, get clearly slower.

Does it return the same results?

On the 43 ClickBench queries, two passes in three modes, every answer passes the author's checker against DuckDB. For queries whose LIMIT cuts a run of tied rows, that checker only compares the sort keys.

Why does an OR with thousands of conditions take so long?

Because the planner builds a path for each condition and the time grows with the square: 16,000 (warehouse, product) pairs take 88 to 96 seconds just to plan, on PostgreSQL 15 to 19. Rewriting it as IN or = ANY per warehouse brings it down to milliseconds.

So should I avoid = ANY with pg_vexec?

In auto mode, with large arrays, for now yes: pg_vexec estimates the ANY as if it used a hash table, one lookup per row, but runs it by comparing every row with every value, so it gives up the index. With 16,000 values the query goes from 73 ms to 69 s. With vexec.mode set to off it doesn't happen.

Did PostgreSQL 18 fix any of this?

Yes, the OR on a single column: PostgreSQL 18 turns it into an ANY, and with 4,000 values it goes from 30 seconds to 76 ms. The OR of column pairs, the original article's, is as slow to plan as ever.

Sources

Measured on 8 and 9 October 2026 on a Lenovo IdeaPad 81F4 (Intel Core i5-8250U, 4 cores and 8 threads, 12 GB of RAM) running NixOS 26.05 and Docker 29.8.1; PostgreSQL 19 from REL_19_STABLE at 2359968b449a (19beta4), pg_vexec 634d868e8549, PostgreSQL 15.19, 16.15, 17.11 and 18.6 from their official images, DuckDB 1.5.5.

Comments

No comments yet. The first one is yours.

Reviewed before publishing. The email is not stored and never appears anywhere.