Skip to content
Go back

A Backend Dev's Performance Tuning in Practice (4): Finding the Bottleneck with Slow Query

Published:  at  08:00 AM

Foreword

On the road to performance tuning, aside from the app’s own performance issues, the database is another topic you can’t avoid. Nowadays, plenty of services can scale horizontally with K8s to boost performance, but the database usually stays a single monolithic instance — and under that setup, the pressure on the database is heavier than ever, which makes database optimization a major topic in its own right. Below, I’ll introduce two of the most common database performance-troubleshooting tools: Slow Query Log and the EXPLAIN syntax.

Slow Query Log

What is Slow Query? Simply put, it’s a SQL feature that logs any statement whose execution time crosses a certain threshold, making it easy for engineers to go back and review. When you want to check whether a particular query is running slow, the Slow Query Log is the first place to look.

You can check the current slow query setting with this command:

SHOW log_min_duration_statement;

The default value is -1, meaning the feature is disabled. In production, it’s recommended to turn it on. Here’s how to set it — say we want to flag any query that takes longer than 100 milliseconds:

ALTER SYSTEM SET log_min_duration_statement = 100;
SELECT pg_reload_conf();

Note that after setting up logging, you need to separately call pg_reload_conf() for the setting to take effect immediately. Also, this operation takes effect right away without needing to restart the DB.

Once that’s set up, we can try a query. For example, I ran the following against a ticket table where I’d pre-loaded over 1.5GB of fake data to simulate a slow query:

SELECT count(*) FROM ticket WHERE user_id = 200;

After running it, you can find the record in the PostgreSQL container log:

...
2026-07-13 02:49:11.796 UTC [302]: LOG:  duration: 245.075 ms  statement: SELECT count(*) FROM ticket WHERE user_id = 200;
2026-07-13 02:49:13.350 UTC [302]: LOG:  duration: 272.983 ms  statement: SELECT count(*) FROM ticket WHERE user_id = 200;
...

As you can see, this “look up a specific user’s tickets” query takes over 200 milliseconds — well past the 100ms threshold we set — which is exactly why we can find it recorded here.

So, we now know it’s slow. But where exactly is it slow? This is where we bring in our next tool: the EXPLAIN syntax.

EXPLAIN is a special piece of SQL syntax that analyzes a query and gives you detailed information about it. It doesn’t actually run the query first — it only performs an upfront analysis of it.

EXPLAIN SELECT count(*) FROM ticket WHERE user_id = 200;

Here’s the result:

Finalize Aggregate  (cost=179475.17..179475.18 rows=1 width=8)
  ->  Gather  (cost=179474.96..179475.17 rows=2 width=8)
        Workers Planned: 2
        ->  Partial Aggregate  (cost=178474.96..178474.97 rows=1 width=8)
              ->  Parallel Seq Scan on ticket  (cost=0.00..178455.61 rows=7737 width=0)
                    Filter: (user_id = 200)
JIT:
  Functions: 6
  Options: Inlining false, Optimization false, Expressions true, Deforming true

Seeing this table for the first time can feel a bit dizzying. Let’s pick out a few key fields:

The most important part here is Parallel Seq Scan on ticket. A Seq Scan is a Full Table Scan — it means the database is going row by row through the entire ticket table looking for user_id = 200. This is usually the culprit behind poor performance — in this case, it’s because there’s no index on the user_id foreign key. But pure EXPLAIN is only an “estimate”; we can verify this by actually running it once with ANALYZE:

EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM ticket WHERE user_id = 200;

Result:

Finalize Aggregate  (cost=179475.17..179475.18 rows=1 width=8) (actual time=290.771..295.209 rows=1.00 loops=1)
  Buffers: shared hit=9916 read=120975
  ->  Gather  (cost=179474.96..179475.17 rows=2 width=8) (actual time=290.640..295.199 rows=3.00 loops=1)
        Workers Planned: 2
        Workers Launched: 2
        Buffers: shared hit=9916 read=120975
        ->  Partial Aggregate  (cost=178474.96..178474.97 rows=1 width=8) (actual time=271.163..271.164 rows=1.00 loops=3)
              Buffers: shared hit=9916 read=120975
              ->  Parallel Seq Scan on ticket  (cost=0.00..178455.61 rows=7737 width=0) (actual time=11.709..270.432 rows=5624.00 loops=3)
                    Filter: (user_id = 200)
                    Rows Removed by Filter: 3038511
                    Buffers: shared hit=9916 read=120975
Planning Time: 0.080 ms
JIT:
  Functions: 14
  Options: Inlining false, Optimization false, Expressions true, Deforming true
  Timing: Generation 2.172 ms (Deform 0.667 ms), Inlining 0.000 ms, Optimization 1.948 ms, Emission 28.265 ms, Total 32.385 ms
Execution Time: 295.586 ms

Adding ANALYZE makes PostgreSQL actually run the query once, so we now get actual time (actual elapsed time); adding BUFFERS on top of that tells us whether the data came from memory or disk. Two numbers in this table explain “where it’s slow” better than anything else:

That’s a latency of about 300 milliseconds — very high. At this point, we can optimize the query speed by adding an index:

CREATE INDEX CONCURRENTLY idx_ticket_user_id ON ticket(user_id);

Running ANALYZE again gives us this:

Aggregate  (cost=435.81..435.82 rows=1 width=8) (actual time=1.882..1.883 rows=1.00 loops=1)
  Buffers: shared hit=22
  ->  Index Only Scan using idx_ticket_user_id on ticket  (cost=0.43..389.39 rows=18569 width=0) (actual time=0.019..1.036 rows=16872.00 loops=1)
        Index Cond: (user_id = 200)
        Heap Fetches: 0
        Index Searches: 1
        Buffers: shared hit=22
Planning:
  Buffers: shared hit=12 read=1
Planning Time: 0.374 ms
Execution Time: 1.935 ms

Putting the key numbers from both reports side by side, the impact becomes crystal clear:

MetricBefore IndexAfter Index
Scan methodParallel Seq Scan (full table scan)Index Only Scan (via index)
Buffers read (disk reads)1209750 (shared hit=22)
Rows Removed by Filter30385110
Execution Time295.586 ms1.935 ms

Execution time dropped from about 300 milliseconds down to 1.9 milliseconds — roughly 150x faster. Even more importantly, it no longer scans the whole table or has to fetch 120,000 blocks off disk — it finds the answer directly through the index. This is exactly why knowing how to read EXPLAIN matters so much: it tells you directly “where it’s slow,” so you know exactly what to treat.

And that’s a simple application of Slow Query + EXPLAIN put together. That’s it for this article.


Suggest Changes
Share this post on:

Next Post
A Backend Dev's Performance Tuning in Practice (3): Grafana & Prometheus Query Cases