PostgreSQL 18 has just been born and we are already talking about the blessings of PostgreSQL 19 and beyond? Well, yes, and there is a good reason for it.
Table of Contents
Recently, an important series of changes have been committed, which some of our folks (and many others around the world) have been working on for many many years and which have the power to seriously speed up aggregations for basically everyone out there. What is even better is that you don't have to change your code, there is no need to adjust parameters or anything of that sort. You can simply run your code as it is and enjoy the benefits of this important improvement.
To understand why this improvement in the query optimizer is so important, we first have to understand how PostgreSQL has handled grouping up to now. The most simple rule was: "Join first, aggregate later". What does that mean? Consider the following example:
|
1 2 3 4 |
SELECT j.gender_name, count(*) FROM person AS p, gender AS j WHERE p.gender_id = j.gender_id GROUP BY j.gender_name |
Let us assume that we only store a handful of genders but millions of people. The way everything before PostgreSQL 19 is handling this type of query is the following. Keep in mind that the example is of course highly simplified to make understanding easy:
person tablegender_name and add to the count in the desired groupWhat is fundamentally wrong with this approach? Actually nothing. This is how most systems would handle this type of operation. However, there is an inefficiency here: In a large table, millions of people might be female - what the system does is to look up the name for each gender_id again and again. In case almost every id is different, this is fine. However, if there are only a handful of different ids, the operation takes way too long and becomes pretty repetitive.
The big breakthrough in PostgreSQL 19 (which is, at the point of writing, under development) is that the optimizer can now decide: Aggregate first and join later or join first and aggregate later? What sounds like a tiny improvement has MASSIVE implications.
In the next listing, you can see some typical code which you might find in countless applications:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
CREATE TABLE t_category ( category_id int4 PRIMARY KEY, category_name text ); INSERT INTO t_category VALUES (0, 'Shoes'), (1, 'Shirts'), (2, 'Car'), (3, 'Bike'); CREATE TABLE t_color ( color_id int4 PRIMARY KEY, color_name text ); INSERT INTO t_color VALUES (0, 'Red'), (1, 'Green'), (2, 'Yellow'), (3, 'Blue'); CREATE TABLE t_product ( category_id int4 REFERENCES t_category (category_id), color_id int4 REFERENCES t_color (color_id), whatever text ); |
The data structure features two lookup tables (for categories and colors), which hardly contain data, and a massive product table consisting of potentially millions of rows. In our example it is populated with just 200.000 rows:
|
1 2 3 |
INSERT INTO t_product SELECT id % 4, (id * random())::int4 % 4, md5(id::text) FROM generate_series(1, 200000) AS id; |
The goal is simple: Count the number of products per category and per color. The query to do this is far from fancy and might look as follows:
|
1 2 3 4 5 |
SELECT category_name, color_name, count(*) FROM t_product AS p, t_category AS c1, t_color AS c2 WHERE p.color_id = c2.color_id AND c1.category_id = c1.category_id GROUP BY 1, 2; |
As you can observe, we are talking about a join spanning just three tables. For every product, all we do is to simply lookup two names. In versions older than PostgreSQL 19 (assuming the patch won't be reverted during development) our execution plan would have looked as follows:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
QUERY PLAN ------------------------------------------------------------------------------------------------------ Finalize GroupAggregate (cost=13167.09..13170.53 rows=16 width=18) Group Key: c1.category_name, c2.color_name -> Gather Merge (cost=13167.09..13170.17 rows=27 width=18) Workers Planned: 1 -> Sort (cost=12167.08..12167.12 rows=16 width=18) Sort Key: c1.category_name, c2.color_name -> Partial HashAggregate (cost=12166.60..12166.76 rows=16 width=18) Group Key: c1.category_name, c2.color_name -> Hash Join (cost=2.49..8637.19 rows=470588 width=10) Hash Cond: (p.color_id = c2.color_id) -> Parallel Seq Scan on t_product p (cost=0.00..3046.47 rows=117647 width=4) -> Hash (cost=2.29..2.29 rows=16 width=14) -> Nested Loop (cost=0.00..2.29 rows=16 width=14) -> Seq Scan on t_category c1 (cost=0.00..1.04 rows=4 width=5) -> Materialize (cost=0.00..1.06 rows=4 width=9) -> Seq Scan on t_color c2 (cost=0.00..1.04 rows=4 width=9) (16 rows) |
What we have to do here is to read the plan "from the inside to the outside" to understand what is going on. The plan starts with a sequential scan on color and category until it finally ends up joining all those lookup tables with the product table. Only then does aggregation (= counting) start. In other words, for every product we had to run two lookups.
Let us compare this to the new method:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
QUERY PLAN ----------------------------------------------------------------------------------------------------------- Finalize GroupAggregate (cost=4636.63..4638.60 rows=15 width=18) Group Key: c1.category_name, c2.color_name -> Gather Merge (cost=4636.63..4638.34 rows=15 width=18) Workers Planned: 1 -> Sort (cost=3636.62..3636.64 rows=9 width=18) Sort Key: c1.category_name, c2.color_name -> Nested Loop (cost=3634.84..3636.48 rows=9 width=18) -> Nested Loop (cost=3634.84..3635.33 rows=2 width=13) -> Partial HashAggregate (cost=3634.71..3634.75 rows=4 width=12) Group Key: p.color_id -> Parallel Seq Scan on t_product p (cost=0.00..3046.47 rows=117647 width=4) -> Index Scan using t_color_pkey on t_color c2 (cost=0.13..0.15 rows=1 width=9) Index Cond: (color_id = p.color_id) -> Materialize (cost=0.00..1.06 rows=4 width=5) -> Seq Scan on t_category c1 (cost=0.00..1.04 rows=4 width=5) (15 rows) |
What we see here is that we immediately approach the product table and aggregate it by those ids, and THEN apply nested loops to join the data. Then, the rest is easy, because after the HashAggregate almost no data is left (only a handful of rows). The trick is that, after aggregating by those ids, we only have to lookup a couple of names which saves countless iterations.
Obviously, the second method is more efficient. The following graph shows the difference one can hope for:
|
1 2 |
old method: 95.3 ms new method: 16.8 ms |
Wow, this is more than 5 times faster. The beauty is: The more lookup tables there are, the bigger the gain we can expect. The improvement is massive.
Side note: First run, no hint bits, fresh statistics, Macbook M3, default PostgreSQL configuration.
While the results are stunning, in most cases and for the vast majority of applications, there are of course also some corner cases or less frequently used features which cannot fully benefit from this functionality. One of them is GROUP BY CUBE:
|
1 2 3 4 5 6 |
explain SELECT category_name, color_name, count(*) FROM t_product AS p, t_category AS c1, t_color AS c2 WHERE p.color_id = c2.color_id AND c1.category_id = c1.category_id GROUP BY CUBE(1, 2); |
The execution plan looks as follows:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
QUERY PLAN ---------------------------------------------------------------------------------------- MixedAggregate (cost=2.49..29372.74 rows=25 width=18) Hash Key: c1.category_name, c2.color_name Hash Key: c1.category_name Hash Key: c2.color_name Group Key: () -> Hash Join (cost=2.49..13372.49 rows=800000 width=10) Hash Cond: (p.color_id = c2.color_id) -> Seq Scan on t_product p (cost=0.00..3870.00 rows=200000 width=4) -> Hash (cost=2.29..2.29 rows=16 width=14) -> Nested Loop (cost=0.00..2.29 rows=16 width=14) -> Seq Scan on t_category c1 (cost=0.00..1.04 rows=4 width=5) -> Materialize (cost=0.00..1.06 rows=4 width=9) -> Seq Scan on t_color c2 (cost=0.00..1.04 rows=4 width=9) (13 rows) |
In this case, we can see that all those aggregations (the idea of CUBE is to perform many of them at a time) are still performed on top. For various reasons this is necessary in this case. However, as I stated before: The use of this functionality is not as common as a simple straight forward GROUP BY.
If you want to learn more about cubes and grouping sets in PostgreSQL, we recommend to check out some relevant blogs about this essential topic:

Hans-Jurgen
What a great discovery again
Would this supersede pg_facetting?
Ronny
Whathappens if I categorize by a dimension table which contains both the fine grained id + name and a broader category. E.g the dimension is on department but also contains divisions where multiple departments share a division? Does GB the do two groups, first by the original department I'd and then after joining by the division name?