aboutsummaryrefslogtreecommitdiff
path: root/content/posts/postgres-aggregation-speed/index.md
blob: 3507d4fafbe1761496e96c8567a4f1333b39eca1 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388

---
title: PostgreSQL aggregation processing speed
draft: true
date: 2025-09-29
lastmod:
description: Comparison of aggregation function speed, on the database and in a host language
categories:
  - PostgreSQL
---
## Key takeaways

**JSON is more efficient than JSONB when only reading data**

JSONB is useful for data processing, when filtering data on the database. But in this test, the higher cost of creating JSONB is not worth it, and there is also a price to convert it back to text.

**Use `composite binary` for Python data processing**

If you need to compute further in Python, the raw speed of composite binary on Postgres and the OK part in Python makes it the fastest to get data in processable data structures. You can even cast the output in a typed `NamedTuple` and won't need manual parsing

## The problem

Most of the time, the rows of a SQL query matches the domain: a list of user data, a list of product data. But sometimes, there is a sub list for each item: each user's top articles.

When a single sub list is required, several rows can be returned by joins for each root item: several rows for each user. The data is then merged in the host language.

But when there are several sub lists, there is an issue with high cardinality: each user has 15 articles, and 30 comments, and that makes 450 rows for each user, with a lot of duplicated data.

In this case, there are list aggregation functions to generate sub lists at the row level. There are several available, such as `json_agg` and `array_agg`.

**When should you use one or the other?**

After performing this article's investigation, I will implement the learnings to a central query in my day job product: a search query. Because it requires some price filtering in Python, the data loading part from SQL can be quite important. The results below show the time spent in each part of the pipeline. Overall, the changes give about a 5% boost to the query, while reducing maintenance cost. Not bad for a query I spent weeks optimising!

{{< chart type="bar" data=`{
  "labels": ["Old", "New"],
  "datasets": [{
    "label": "Wait (ms)",
    "data": [1095, 1100],
    "backgroundColor": "rgba(16, 185, 129, 0.6)",
    "borderColor": "rgba(16, 185, 129, 1)",
    "borderWidth": 1,
    "barThickness": 8
  }, {
    "label": "Psycopg parsing (ms)",
    "data": [45, 180],
    "backgroundColor": "rgba(245, 158, 11, 0.6)",
    "borderColor": "rgba(245, 158, 11, 1)",
    "borderWidth": 1,
    "barThickness": 8
  }, {
    "label": "Custom parsing (ms)",
    "data": [350, 140],
    "backgroundColor": "rgba(91, 30, 114, 0.6)",
    "borderColor": "rgba(91, 30, 114, 1)",
    "borderWidth": 1,
    "barThickness": 8
  }]
}` options=`{
  "indexAxis": "y",
  "scales": {
    "x": { "stacked": true, "title": { "display": true, "text": "Time (ms)" },
    "y": { }
    }
  },
  "plugins": {
    "title": { "display": true, "text": "Case study" }
  }
}` >}}

## Data model and setup

I use a simple data model, and I can tweak the size of the dataset so that the benchmark is interesting.

The model is an `Accommodation`, that has a default price for rental, and then many `Override` that would allow to specify different prices along the year. There are 10 000 accommodations and 1 000 000 overrides.

![Data model](data-model.svg)

```sql
CREATE TABLE accommodations (
    id serial PRIMARY KEY,
    name text NOT NULL,
    default_price numeric(10, 2) NOT NULL
);

INSERT INTO accommodations (name, default_price)
SELECT
    CONCAT('accommodation_', generate_series) AS name,
    TRUNC(RANDOM() * 100 + 200) AS default_price
FROM GENERATE_SERIES(1, 10000);

CREATE TABLE overrides (
    id serial PRIMARY KEY,
    accommodation_id integer NOT NULL REFERENCES accommodations (id),
    date date NOT NULL,
    price numeric(10, 2) NOT NULL
);
ALTER TABLE overrides ADD constraint unique_dates UNIQUE (
    accommodation_id, date
);
INSERT INTO overrides (accommodation_id, date, price) SELECT
    (generate_series / 100) + 1 AS accommodation_id,
    '2025-01-01'::date + (generate_series % 100) * '3 days'::interval AS date,
    TRUNC(RANDOM() * 100 + 200) AS price
FROM GENERATE_SERIES(1, 999999);
```

## Postgres query engine performance

The first question is: **what is the fastest query?**

The three data types used are:
- `composite`, which is like a type safe tuple in any programming language
- `json`, which represent a JSON value and is stored as text
- `jsonb`, also represents a JSON value, but stored in an optimised format for querying

Let's take a look at the five queries:

**Composite query**

```sql
SELECT
    accommodation_id,
    array_agg((date, price)) AS daily_configs
FROM overrides
WHERE '[2025-07-01,2025-08-01)'::daterange @> date
GROUP BY overrides.accommodation_id
```

**JSON object query**

```sql
SELECT
    accommodation_id,
    json_agg(json_build_object(
        'date', date,
        'price', price::text
    )) AS daily_configs
FROM overrides
WHERE '[2025-07-01,2025-08-01)'::daterange @> date
GROUP BY overrides.accommodation_id
```

**JSON array query**

```sql
SELECT
    accommodation_id,
    json_agg(json_build_array(date, price::text)) AS daily_configs
FROM overrides
WHERE '[2025-07-01,2025-08-01)'::daterange @> date
GROUP BY overrides.accommodation_id
```

**JSONB object query**

```sql
SELECT
    accommodation_id,
    jsonb_agg(jsonb_build_object(
        'date', date,
        'price', price::text
    )) AS daily_configs
FROM overrides
WHERE '[2025-07-01,2025-08-01)'::daterange @> date
GROUP BY overrides.accommodation_id
```

**JSONB array query**

```sql
SELECT
    accommodation_id,
    jsonb_agg(jsonb_build_array(date, price::text)) AS daily_configs
FROM overrides
WHERE '[2025-07-01,2025-08-01)'::daterange @> date
GROUP BY overrides.accommodation_id
```

They look pretty similar.

I run these five queries with `EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS)`, and I can note their total time, alongside the different nodes duration in the plan.

{{< chart type="bar" data=`{
  "labels": ["composite", "json array", "jsonb array", "json object", "jsonb object"],
  "datasets": [{
    "label": "Seq Scan (ms)",
    "data": [39, 37, 35, 40, 40],
    "backgroundColor": "rgba(16, 185, 129, 0.6)",
    "borderColor": "rgba(16, 185, 129, 1)",
    "borderWidth": 1,
    "barThickness": 8
  }, {
    "label": "HashAggregate (ms)",
    "data": [40, 86, 112, 120, 180],
    "backgroundColor": "rgba(245, 158, 11, 0.6)",
    "borderColor": "rgba(245, 158, 11, 1)",
    "borderWidth": 1,
    "barThickness": 8
  }]
}` options=`{
  "indexAxis": "y",
  "scales": {
    "x": { "stacked": true, "title": { "display": true, "text": "Time (ms)" },
    "y": { }
    }
  },
  "plugins": {
    "title": { "display": true, "text": "PostgreSQL Query Performance" }
  }
}` >}}

- `Seq Scan` is stable for all queries, which makes sense because the same data is required.
- `HashAggregate` are different because of the choice of data processing
- `composite` is the fastest
- `json` is faster than `jsonb`
- `array` is faster than `object`

I thought that JSONB was faster than JSON, but I now understand it is faster for things like filtering data. But in this case, I pay for encoding in the first place, and there is no gain to expect. Only when the source column is JSONB encoded, there is no cost for encoding: it is paid on write.

Array being faster than objects make sense: there is less structure, the data is easier to create. I think there are places where each kind can be useful.

I wasn't sure if Postgres output type (binary or not) would impact the results, but after a quick benchmarks it looks like not.

## Server side

The previous part shows that `composite` is the fastest to execute on the database. But what really matters is the moment when the data is ready to use in the host language. There are two main use cases:
- **Host processing:** Once the data is ready, I need to apply an algorithm to it: I have a function that can compute the precise pricing for a stay, and then I filter by a maximum price.
- **JSON output:** The data does not need processing, it is sent back to a client in JSON shape

So each use case may have a unique best solution.

For this experiment, I use Python with `psycopg`, which is a Postgres driver for Python that supports low level configurations. One configuration is `text` versus `binary` output, and applies to each query.

### Python processing

I need to fetch the data and expose it in meaningful Python types. The expected format looks like: `list[tuple[str, list[tuple[date, Decimal]]]]`

```py
[
	(
		"ab18fc69-2f7d-4331-a9d2-615a7957a143",
		[
			(date(2025, 9, 28), Decimal("200.50")),
			...
		],
	)
]
```

This format may give a boost to `composite binary`, because the driver converts the binary in this exact format. Otherwise I need to manually convert the data. I use the `array` version of all `json` over `object`, because object are costlier the create and read.

I use pytest and pytest-benchmark to write the benchmark. Here is a sample of the code I have to write, but the full file is available at the end.

```py
def load_any_json_array_json(
    conn: Connection, query: bytes, *, binary: bool
) -> JSONOut:
    with conn.cursor() as cursor:
        cursor.execute(query, binary=binary)
        rows = cursor.fetchall()

    return [
        (
            row[0],
            [{"date": item[0], "price": item[1]} for item in row[1]],
        )
        for row in rows
    ]

#...

@pytest.mark.benchmark(group="json out")
class TestJSONOut:
    @pytest.mark.parametrize("binary", [False, True])
    @pytest.mark.parametrize(
        "expr",
        [JSON_OBJECT_EXPR, JSONB_OBJECT_EXPR],
        ids=["json object", "jsonb object"],
    )
    def test_json_object(
        self,
        conn: Connection,
        benchmark: BenchmarkFixture,
        binary: bool,
        expr: str,
    ):
        benchmark(
            load_any_json_object_json, conn, BASE_QUERY.format(expr), binary=binary
        )
```

{{< chart type="bar" data=`{
  "labels": ["composite binary", "json array text", "json array binary", "jsonb array binary", "jsonb array text", "composite text"],
  "datasets": [{
    "label": "PostgreSQL (ms)",
    "data": [80, 120, 120, 150, 150, 80],
    "backgroundColor": "rgba(59, 130, 246, 0.6)",
    "borderColor": "rgba(59, 130, 246, 1)",
    "borderWidth": 1,
    "barThickness": 8
  }, {
    "label": "Python (ms)",
    "data": [140, 110, 110, 110, 120, 230],
    "backgroundColor": "rgba(245, 158, 11, 0.6)",
    "borderColor": "rgba(245, 158, 11, 1)",
    "borderWidth": 1,
    "barThickness": 8
  }]
}` options=`{
  "indexAxis": "y",
  "scales": {
    "x": { "stacked": true, "title": { "display": true, "text": "Time (ms)" } },
    "y": { }
  },
  "plugins": {
    "title": { "display": true, "text": "Python Processing Performance" }
  }
}` >}}

- JSON parsing seems stable across all runs
- Composite binary seems a little slower, but not by much
- Composite text is much slower

Composite is not much faster, which can be odd. If `psycopg` uses pure python for data transformation, doing it by hand is not slower: the [NumericLoader](https://github.com/psycopg/psycopg/blob/c9e013d4441d9c9ae5bed61ce8fa561dcb0b9dae/psycopg_c/psycopg_c/types/numeric.pyx#L440) implementation uses optimised Python, but relies on the `Decimal` constructor. The speed on the database makes it the best choice still.

Composite text is very slow. Even tuples need to be parsed manually. I don't get why JSON isn't as slow: it needs to be parsed as array too. The [CompositeLoader](https://github.com/psycopg/psycopg/blob/c9e013d4441d9c9ae5bed61ce8fa561dcb0b9dae/psycopg/psycopg/types/composite.py#L243) is pure Python, while Python's own JSON is certainly optimised C.

### JSON API

I have to fetch the data and format it as JSON. I'm not pushing it to the point i keep it as strings, but it might be the better answer. I'm only interesting in aggregations, so I only format the sub list. The format looks like: `list[tuple[str, list[dict[str, str]]]]`

```py
[
    (
        "ab18fc69-2f7d-4331-a9d2-615a7957a143",
        [
	        {"date": "2025-09-28", "price": "200.50"}
	    ],
    )
]
```

This time, I use JSON objects, as this is the shape of the data transmitted. It should give a boost to all JSON queries, while composite still requires processing.

{{< chart type="bar" data=`{
  "labels": ["json object binary", "json object text", "jsonb object binary", "composite text", "jsonb object text", "composite binary"],
  "datasets": [{
    "label": "PostgreSQL (ms)",
    "data": [160, 160, 220, 80, 220, 80],
    "backgroundColor": "rgba(59, 130, 246, 0.6)",
    "borderColor": "rgba(59, 130, 246, 1)",
    "borderWidth": 1,
    "barThickness": 8
  }, {
    "label": "Python (ms)",
    "data": [60, 70, 50, 200, 60, 200],
    "backgroundColor": "rgba(245, 158, 11, 0.6)",
    "borderColor": "rgba(245, 158, 11, 1)",
    "borderWidth": 1,
    "barThickness": 8
  }]
}` options=`{
  "indexAxis": "y",
  "scales": {
    "x": { "stacked": true, "title": { "display": true, "text": "Time (ms)" },
    "y": { }
    }
  },
  "plugins": {
    "title": { "display": true, "text": "JSON API Performance" }
  }
}` >}}

- All `json` processing look the same
- `composite` in much slower in Python, regardless of binary or text

All JSON outputs are ready for output, so they require the less work on the Python side.

`composite binary` is definitely slower: `psycopg` pays for the conversion in Python `date` and `Decimal`, and then again for converting it back manually.

I don't really know why `composite text` is that slow in both benchmarks.

## Resources

- [Benchmarking script](test.py)
- [Postgres JSON functions](https://www.postgresql.org/docs/current/functions-json.html)
- [Postgres aggregation functions](https://www.postgresql.org/docs/current/functions-aggregate.html)
- [Psycopg](https://www.psycopg.org/)