DevelopmentIndustry Guide14 min readPublished June 30, 2026

Indexes · N+1 · pooling · PostgreSQL 18 current · anchored to the official docs

Postgres Performance Tuning for App Developers 2026

A field guide to fixing slow Postgres from the application side — missing and wrong indexes, the N+1 queries your ORM fires quietly, connection-pool exhaustion, the session features PgBouncer transaction-mode pooling silently disables, and how to choose B-tree versus GIN versus BRIN. Written for the developer who inherited a slow API route, not for the DBA who owns the cluster.

DA
Digital Applied Team
Senior engineers · Published Jun 30, 2026
PublishedJun 30, 2026
Read time14 min
Sources11 primary
Root causes
3
indexes · N+1 · pool exhaustion
Index methods
6
B-tree, Hash, GiST, SP-GiST, GIN, BRIN
Pool modes
3
session · transaction · statement
Session features
6+
disabled by transaction-mode pooling

Postgres performance tuning, for most app developers, does not start with a database to administer — it starts with a slow API route and a connection string you do not fully control. You own the repo, the ORM, and a managed Postgres endpoint, and a page is timing out. The reassuring part is that the overwhelming majority of slow-Postgres reports trace back to a short, fixable list, and you can work through it from the application side without ever editing a config file you are not allowed to touch.

That matters more in 2026 than it used to. Most teams now run Postgres as a managed, often serverless endpoint — Supabase, Neon, Aurora — where you cannot SSH in to tweak the box, but you can still change your queries, your indexes, and how you connect. PostgreSQL 18, released September 25, 2025, is the current major version through 2026, and it shifted a few defaults worth knowing. The tuning that actually moves your latency lives almost entirely in things you control from the app.

This guide walks the checklist in the order a real investigation takes it: where slow queries come from, how to read an EXPLAIN plan without misreading it, which index type a given query actually wants, the N+1 pattern ORMs create by default, the connection pooling every serverless app needs and the session features it quietly breaks, and how vacuum, partitioning, and read replicas fit once a single primary stops being enough.

Key takeaways
  1. 01
    Three causes explain most slow Postgres.Missing or wrong indexes, N+1 query patterns from ORM lazy loading, and connection-pool exhaustion account for the bulk of slow-Postgres reports in application codebases. Work them in that order before you reach for bigger hardware or a read replica.
  2. 02
    Read the plan, do not skim it.EXPLAIN shows estimated cost; EXPLAIN ANALYZE shows real time, rows, and loops by running the query. When loops is greater than one, actual time and rows are per-loop averages — multiply by loops — and a wide gap between estimated and actual rows points to stale statistics that ANALYZE refreshes.
  3. 03
    Index type is a decision, not a default.Postgres ships six built-in index methods. B-tree is the default; GIN handles JSONB, arrays, and full-text; BRIN suits huge append-only tables ordered by time; partial and covering indexes cut size and skip the heap. Picking the wrong one leaves performance on the table.
  4. 04
    Pooling is mandatory for serverless — and it breaks things.PgBouncer transaction-mode pooling is the standard fix for connection exhaustion, but it disables six or more session-scoped features and trips up ORM prepared statements. Prisma and Rails each need specific configuration, and some code paths need a direct, unpooled connection.
  5. 05
    Vacuum is not optional housekeeping.Autovacuum reclaims dead rows, refreshes statistics, maintains the visibility map for index-only scans, and prevents transaction-ID wraparound. Its defaults are conservative; hot, high-churn tables usually want per-table overrides rather than cluster-wide changes.

01The Three CausesWhere a slow query actually comes from.

Before you tune anything, name the failure. Most slow-Postgres reports in an application codebase collapse into three recurring shapes, and recognizing which one you are looking at is most of the work. The first is an indexing problem: the planner falls back to a sequential scan because there is no index that fits the query, or the index that exists is the wrong type. The second is an N+1 pattern, where an ORM fires one query per row instead of one query per request. The third is connection-pool exhaustion, where a flood of short-lived connections — the serverless signature — overwhelms the database long before any single query is the problem.

These three are not equally easy to spot. A missing index announces itself in the query plan. An N+1 pattern hides in your ORM and only shows up if you count the queries a single request fires. Pool exhaustion looks like the database falling over under load while every individual query still runs fast in isolation. The diagnostic move that cuts through all three is the same: stop guessing and make the database tell you what it is doing — through the query plan, the query log, and your connection metrics.

Cause 1
Missing or wrong indexes
Seq Scan in the plan

A sequential scan with a high Rows Removed by Filter is the tell. The fix is the right index type, not just any index — and the type depends on the query shape.

Most common
Cause 2
N+1 query patterns
ORM lazy loading

One query fetches the list, then one more fires per row while you iterate — N+1 round trips where eager loading needs one or two. It is an ORM default, not a database flaw.

ORM default
Cause 3
Pool exhaustion
too many connections

Serverless functions each open a connection until the database runs out of slots. The fix is a pooler, not a bigger max_connections — raising the cap usually makes it worse.

Serverless killer
What PostgreSQL 18 changed in 2026
A handful of PostgreSQL 18 changes matter for tuning. A new asynchronous I/O subsystem issues storage reads concurrently rather than one at a time; the project reports gains of up to 3x on read-heavy work like sequential scans, bitmap heap scans, and VACUUM — read that as a vendor-stated ceiling, not an average to plan around. Postgres 18 also added a built-in uuidv7() that produces time-ordered UUIDs, which index more tightly than random UUIDv4 because new values append to the end of the B-tree instead of scattering across it, plus new vacuum knobs covered in section 06. Most older tuning posts still ranking in search predate all of this.

02Reading EXPLAINReading the plan the way Postgres writes it.

Every tuning decision starts here, and most developers misread the output. Plain EXPLAIN shows the planner’s estimate — the cost it expects, written as (startup_cost..total_cost), where startup cost is the estimated work before the first row can be returned and total cost is the estimate to return all of them. Those numbers are in the planner’s own arbitrary units, not milliseconds, and they are only a guess. EXPLAIN ANALYZE actually runs the statement and reports actual time, real rows, and loops — the numbers you should be acting on.

The single most common mistake is the loops trap. When a plan node’s loops count is greater than one — the classic case is the inner side of a nested-loop join — the displayed actual time and rows are per-loop averages, not totals. You have to multiply by loops to get the node’s real contribution. A node that reads as a harmless 0.05ms can be the whole query once you notice it ran ten thousand times.

cost
startup..total
planner estimate

(startup_cost..total_cost) — work before the first row, then work to return all rows. Arbitrary planner units, not milliseconds, and only an estimate.

Estimate only
actual time
real milliseconds
from EXPLAIN ANALYZE

Real timing, real rows, and loop counts produced by actually executing the statement. This is the column you tune against, not the estimated cost.

Runs the query
loops
per-loop average
multiply it out

When loops is greater than one, actual time and rows are per-loop averages. Multiply by loops for the node's true cost — the classic nested-loop misread.

The common trap
The estimate-versus-actual tell
A large gap between a plan’s estimated rows and the actual row count is the primary signal of stale statistics. When the planner thinks a step returns 10 rows and it really returns 100,000, it can pick a nested loop where a hash join would have won — and the whole plan tips over. Running ANALYZE on the affected table refreshes those estimates and often fixes the plan without you touching a single query.

Reading one plan is a skill; finding the plans worth reading is a workflow. In production, the pg_stat_statements extension is the standard way to surface slow queries — it tracks per-normalized-query calls, total_exec_time, and mean_exec_time. Order by mean_exec_time to find the individually expensive queries; order by total_exec_time to find the ones costing the most aggregate cluster time — often a fast query run a million times, not a slow one run twice. The two lists rarely match, and the second is usually where the cheap wins hide.

03Index TypesThe index type is a decision, not a default.

Postgres ships six built-in index access methods, and reaching for B-tree every time leaves real speed unclaimed. B-tree is the right default for equality, range, and sorted retrieval on scalar columns. But GIN is what you want for JSONB containment, arrays, and full-text search — slower to write, faster to read for those types. BRIN stores only min/max summaries per block range instead of per-row entries, which makes it tiny — Supabase documents it as roughly 10x smaller than the equivalent B-tree — but it only helps when the column’s values track the table’s physical row order, the classic case being an append-only created_at timestamp.

Two refinements solve more app problems than a new index method does. A partial index — an index with a WHERE clause — shrinks both size and lookup time when your queries consistently filter to a subset, the canonical example being indexing only active or non-deleted rows. A covering index uses the INCLUDE clause to attach payload columns so a query can be answered straight from the index without visiting the table heap — an index-only scan. The decision table below maps each type to the query shape it serves, its write cost, and the gotcha that bites people.

A decision-oriented reference for the Postgres index types an application developer reaches for most: B-tree, partial, composite, covering (INCLUDE), GIN, and BRIN, mapped to the query shape each serves, its write-cost profile, a typical app use case, and a one-line gotcha. Query-shape, write-cost, and gotcha details are drawn from the official PostgreSQL index-types, GIN, and index-only-scan documentation, the Supabase indexing guide, and Use The Index, Luke; the BRIN size figure is Supabase’s characterization and the use-case column is Digital Applied editorial framing.
Index typeBest query shapeWrite costTypical app use caseOne-line gotcha
B-treeEquality, range, and ORDER BY on scalar columns (the default)Low — the baseline index costForeign keys, lookups, sorting — anything you filter or order onIt is the default for a reason; reach elsewhere only when the query shape does not fit.
PartialQueries that always filter to the same subset of rowsLower — fewer rows are indexedIndex only active or non-deleted rows instead of the whole tableThe query WHERE must match the index predicate or the planner ignores it.
CompositeMulti-column filters where leading columns drive the searchModerate — one index covers several predicatesWHERE tenant_id = ? AND created_at > ?Order equality columns first, then range, then sort — not most-selective-first (a myth).
Covering (INCLUDE)Queries answerable entirely from the index (index-only scan)Higher — payload columns enlarge the indexAttach the SELECTed columns as INCLUDE payload to skip the heapNeeds a current visibility map; write churn forces a heap visit anyway. B-tree, GiST, SP-GiST only.
GINJSONB containment, arrays, and full-text searchHigh — slower to write, faster to readContainment on a JSONB column, array membership, tsvector searchCostly on write-heavy tables; best reserved for read-heavy workloads.
BRINRange scans on physically-ordered columnsVery low — min/max per block range, not per rowHuge append-only tables ordered by an increasing key like created_atOnly helps when values correlate with physical row order; can be roughly 10x smaller than B-tree.

The most expensive index mistake is not the type — it is the column order on a composite index. The instinct to put the “most selective column first” is a myth that Markus Winand’s reference explicitly debunks. A two-column index works like a phone book sorted by surname, then first name: it cannot efficiently search on the second column alone. The rule that actually holds is equality columns first, then range columns, then the columns you sort by. Get that order wrong and the index sits unused while you wonder why it did not help.

"The most important consideration when defining a concatenated index is how to choose the column order so it can be used as often as possible."— Markus Winand, Use The Index, Luke

Two operational notes keep index work safe in production. Adding an index on a live table should almost always use CREATE INDEX CONCURRENTLY, which avoids the write-blocking lock a plain CREATE INDEX takes, at the cost of a longer build and a rare risk of an invalid index if the build is interrupted. And remember that index-only scans depend on the visibility map being current — on a write-heavy table that vacuums infrequently, Postgres falls back to checking the heap anyway, silently negating your covering index. If you are already running pgvector on the same instance, the same tuning rules apply to your embeddings table; our self-hosted RAG with Postgres and pgvector guide walks through indexing a vector column specifically.

04The N+1 TrapThe N+1 query trap your ORM sets quietly.

The N+1 query problem is an ORM artifact, not a database one. With default lazy loading, one query fetches a list of parent records, and then a separate query fires every time you touch a related record while iterating — render a list of 50 orders and reference each order’s customer, and you have just run 51 queries where two would do. Each one is fast in isolation, which is exactly why it hides: nothing looks slow in pg_stat_statements except the sheer call count. The damage is round trips, and it compounds as the result set grows.

The fix is eager loading — telling the ORM to fetch the related records up front in one or two queries instead of N. Every major ORM has the same capability under a different name, and learning your framework’s version is the single highest-leverage Postgres habit an app developer can build. The point is not the exact method; it is the shift from one-query-per-row to one-query-per-relationship.

Django
select_related / prefetch_related
eager loading

select_related follows to-one relations with a SQL join; prefetch_related batches a second query for to-many relations. Either collapses the per-row queries into a fixed count.

Python
Rails
includes / eager_load
eager loading

ActiveRecord's includes preloads associations and eager_load forces a single LEFT OUTER JOIN. Both replace the lazy per-record access that produces the N+1 pattern.

ActiveRecord
SQLAlchemy
joinedload / selectinload
eager loading

joinedload pulls related rows in the same SELECT; selectinload issues one batched IN query for the whole set. Choose by relation shape, but choose one before iterating.

Python

One honest caveat keeps this from turning into a different problem: eager loading is not free, and over-fetching is its own anti-pattern. A blanket join that pulls every association on every request can move the cost from round trips to payload size and memory. The discipline is to eager-load the relations a given endpoint actually uses — no more — and to verify the result by counting queries per request in development, where an N+1 is obvious, rather than waiting for it to surface as latency in production.

05Pooling & PgBouncerPooling is mandatory, and it breaks things.

Serverless changed the connection math. A traditional app server holds a small, stable pool of connections; a fleet of serverless functions opens a fresh connection per invocation and can exhaust the database’s slots almost instantly. A self-managed box with one vCPU and a few gigabytes of RAM tops out in the low hundreds of direct connections, while a managed pooled endpoint like Neon’s accepts up to 10,000 client connections funneled into a much smaller backend pool, with queued requests timing out after 120 seconds if no backend frees up. The lesson is the same everywhere: the fix for connection exhaustion is a pooler, not a bigger max_connections. Connection-pool exhaustion and missing rate limits are really two sides of the same capacity problem, which our API rate limiting engineering reference covers from the request-volume side.

PgBouncer is the de facto pooler, and it offers three pool modes. Session mode hands one server connection to a client for its whole session — full feature compatibility, least reuse. Transaction mode returns the server connection to the pool after each transaction — the standard choice for serverless and high concurrency, because it reuses a small backend pool across many clients. Statement mode returns the connection after each statement, which breaks multi-statement transactions and is rarely used. Transaction mode is what you almost certainly want, and it is also where the surprises live.

Transaction-mode pooling disables every session-scoped feature, because the backend connection underneath you changes between transactions. That list includes SET and RESET of session variables, LISTEN and NOTIFY, WITH HOLD cursors, SQL-level PREPARE and DEALLOCATE, temporary tables, the LOAD statement, and session-level advisory locks. Any code path that genuinely needs one of these has to run on a direct, unpooled connection. The matrix below cross-references the three pool modes against the ORMs and drivers most app teams use, and what each one requires to behave.

A compatibility matrix cross-referencing the three PgBouncer pool modes (session, transaction, statement) against the session features each disables and the configuration required by Prisma, Rails/ActiveRecord, Django, and the raw node-postgres driver, with a recommended use case. Pool-mode definitions are from the PgBouncer features page; the session-feature list is from Neon’s connection-pooling documentation; the Prisma and Rails cells are from Prisma’s PgBouncer documentation and Rails issue 1627; the Django and node-postgres cells apply the documented transaction-mode restrictions; the recommended-use column is Digital Applied editorial guidance.
Pool modeSession features disabledPrismaRails / ActiveRecordDjangonode-postgres (pg)Best for
SessionNothing — full session-feature compatibilityWorks as-is; no special flagPrepared statements work (default on)Works as-isWorks as-isLong-lived servers with few clients; least connection reuse
TransactionSET/RESET, LISTEN/NOTIFY, WITH HOLD cursors, SQL PREPARE/DEALLOCATE, temp tables, LOAD, session advisory locksDo not set pgbouncer=true on PgBouncer 1.21+; use a pooled URL plus a direct URL for migrationsSet prepared_statements: false, or rely on PgBouncer 1.21+ max_prepared_statementsKeep session-scoped state out of pooled queries — the same feature limits applyThin driver: avoid SQL-level PREPARE and per-session SET on the pooled endpointServerless and high-concurrency apps — the standard choice
StatementMulti-statement transactions break — the connection returns after every statementNot viable — ORM transactions span multiple statementsNot viable for typical ActiveRecord useNot viable for typical ORM useOnly single-statement, autocommit pathsRare; specialized single-statement workloads only
The prepared-statement history that bites
Prepared statements are the recurring gotcha, because they live at the session level while transaction pooling hands out a different backend per transaction. The good news, as Crunchy Data’s Greg Sabino Mullane noted: “Version 1.21 of PgBouncer, the Postgres connection pooler, has added a long awaited feature: support for prepared statements inside of transaction mode.” That support is protocol-level only, not SQL-level PREPARE. The practical consequences: Prisma warns against its own pgbouncer=true flag on PgBouncer 1.21+ and recommends a pooled URL for the client plus a direct URL for migrations, while Rails surfaces intermittent invalid-statement errors unless you set prepared_statements: false or configure max_prepared_statements.

Step back and the trend is the real story. As Postgres moved from a box you administer to a managed endpoint you connect to, pooling crossed the line from an optional optimization to a non-negotiable part of any serverless deployment — and the friction migrated with it, out of the database and into the ORM layer, where most teams are least prepared for it. The pattern that holds up is two connection strings, not one: a pooled URL for the high-concurrency runtime client, and a direct, unpooled URL for migrations, admin tasks, and anything that needs a stable session. Most production incidents in this area come from routing a session-dependent operation through the pooled endpoint by accident.

06Vacuum & AutovacuumVacuum is not optional housekeeping.

Postgres keeps old row versions around when you update or delete, and vacuum is what reclaims that space. The official docs give it four jobs: reclaim and reuse the space dead rows occupy, refresh the planner statistics your query plans depend on, maintain the visibility map that makes index-only scans possible, and — most critically — prevent transaction-ID wraparound. Autovacuum runs all four in the background, and most of the time you should let it. The failure mode is when it silently falls behind on a hot table and your plans degrade as the statistics go stale and dead rows pile up.

Trigger
Dead rows before vacuum
20%

autovacuum_vacuum_scale_factor defaults to 0.2, so autovacuum fires once roughly 20% of a table's rows are dead, on top of a 50-row threshold floor.

scale_factor 0.2
Workers
Concurrent autovacuum workers
3

autovacuum_max_workers defaults to 3 and the launcher checks every minute (autovacuum_naptime). On a busy cluster, three workers can fall behind dozens of hot tables.

max_workers
Freeze
Transactions to forced freeze
200M

autovacuum_freeze_max_age defaults to 200 million transactions before a forced anti-wraparound vacuum — the backstop against the wraparound limit below.

freeze_max_age
The one that can take you down
Of vacuum’s four jobs, wraparound is the one that ends in an outage if ignored. The PostgreSQL documentation is blunt: “To avoid this, it is necessary to vacuum every table in every database at least once every two billion transactions.” A table that escapes autovacuum long enough can force the database into a protective shutdown to prevent data loss. The default freeze settings are designed to make that nearly impossible — provided autovacuum is actually keeping up, which on a high-churn table is exactly what you have to verify rather than assume.

When autovacuum cannot keep pace, the right move is rarely a cluster-wide change. Because tables generate dead tuples at very different rates, the documented best practice is per-table overrides via ALTER TABLE ... SET — vacuum a handful of hot, high-churn tables more aggressively without over-vacuuming the quiet ones. The gentle lever is raising autovacuum_vacuum_cost_limit from its default of 200; the more effective one is lowering autovacuum_vacuum_cost_delay from its default of 2ms, down to 0 to let autovacuum run as fast as a manual VACUUM. Postgres 16 added vacuum_buffer_usage_limit to cap how much of shared buffers a vacuum evicts, and Postgres 18 added autovacuum_vacuum_max_threshold to cap the absolute dead-tuple count that triggers autovacuum on very large tables — so the percentage-based scale factor cannot delay vacuum too long on a huge table.

One partitioning-specific gap deserves a callout of its own here, because it surprises people. The docs explicitly warn that autovacuum does not run ANALYZE on the parent partitioned table itself — only on the individual partitions. That leaves the partitioned table’s aggregate statistics stale, and the planner picks suboptimal plans across partitions as a result. After a bulk load or a major data shift, run ANALYZE on the parent manually. It is one line, and it is the documented workaround for a problem that otherwise looks like an inexplicable plan regression.

07Scaling OutWhen one primary is no longer enough.

Only after the query, the index, and the pool are right does hardware-shaped scaling earn its place. The order matters: a read replica or a partition scheme bolted onto an unindexed N+1 query just scales the waste. When you have genuinely outgrown a single primary, three tools sit in roughly increasing order of operational cost — and often the cheapest one, a cache layer, is the one to try first. Before reaching for a read replica, a Redis cache in front of Postgres frequently buys more headroom for less operational cost, an approach our Redis caching strategies for Next.js production guide lays out in full.

Cache first
Redis in front of Postgres

Before a read replica, a cache layer often buys more headroom for less operational cost — and it relieves the queries that hit hardest, not just the slowest ones.

Cheapest headroom
Partition
Range, list, or hash

Split a huge table so the planner can prune irrelevant partitions entirely. Range by date, list by tenant, hash for even spread — and ANALYZE the parent after big loads.

Very large tables
Read replica
Asynchronous, read-only

Offload reporting and read-heavy traffic to an async replica. It tolerates some lag, so it is not safe for read-after-write without routing that read back to the primary.

Read scaling
Pool, do not tune
Pooler over max_connections

For raw serverless concurrency, a transaction-mode pooler is the fix, not raising max_connections — a lesson worth repeating because the wrong instinct is so common.

Concurrency

Partitioning and replication each carry a sharp edge worth naming. Postgres supports range, list, and hash partitioning, and partition pruning — the planner skipping irrelevant partitions — is on by default and can happen at plan time for constants or execution time for parameterized values. Common practitioner guidance, not an official Postgres rule, is to keep partition counts modest (often cited in the tens) so planning overhead does not swamp the benefit; the official docs only say to balance manageability against planning cost. Read replicas, for their part, are asynchronous by default and lag is driven mainly by DDL holding exclusive locks the replica must replay, heavy read load on the replica competing for replay resources, and network throughput between the two — the standard checklist when a replica falls behind.

Looking forward, the trajectory is friendlier to app developers than it has been. PostgreSQL ships a new major version roughly annually under a policy that guarantees five years of support per release with no separate LTS tier, and the recent direction — asynchronous I/O, smarter vacuum thresholds, built-in time-ordered UUIDs — keeps moving performance wins from the DBA’s console into defaults you inherit for free. The next major version is expected on that annual cadence; the durable bet is that more of this checklist becomes automatic over time, not less, which makes learning the diagnostic skills now the thing that keeps paying off.

08ConclusionTune the query before you scale the hardware.

The app developer's Postgres checklist

Most slow Postgres is fixable from the application side, in order.

The throughline of every section here is that you rarely need to administer the database to fix it. Read the plan and trust actual time and loops over the estimate. Match the index type to the query shape instead of defaulting to B-tree. Kill N+1 patterns with eager loading. Put a transaction-mode pooler in front of any serverless workload, and keep a direct connection for the session-dependent code paths it breaks. Let autovacuum do its job, and override it per table when a hot table outpaces it.

Keep the hedges honest as you apply this. Tooling versions drift — the PgBouncer 1.25.x line is current as of mid-2026, and patch releases land often — so anchor the specifics to the official PostgreSQL and vendor docs rather than to a number in a blog post. Vendor throughput figures, including PostgreSQL 18’s up-to-3x I/O claim, are ceilings, not averages. The defaults and behaviors in this guide are stable, but always confirm a precise value against the primary source before you change production.

Done in this order, the work compounds: a correctly indexed, N+1-free, well-pooled application running on a vacuumed database handles far more load on the same hardware than most teams expect, and it pushes the expensive scaling decisions out by months. That sequencing — fix the cheap things first, scale only what is left — is exactly how our web development engagements approach a slow database: diagnose from the app, change the smallest thing that works, and reach for new infrastructure last.

Make a slow database fast without re-platforming

Fix the query, the index, and the pool before you scale the hardware.

We help product and engineering teams diagnose slow Postgres from the application side — reading plans, choosing index types, killing N+1 patterns, and getting serverless pooling right before anyone provisions a read replica.

Free consultationExpert guidanceTailored solutions
What we work on

Database performance engagements

  • EXPLAIN-plan audits on your slowest production endpoints
  • Index-type review — B-tree, GIN, BRIN, partial, covering
  • N+1 elimination across Django, Rails, and SQLAlchemy
  • PgBouncer pool-mode setup and ORM connection-string hardening
  • Autovacuum tuning and per-table overrides for hot tables
FAQ · Postgres performance for app developers

The questions we get every week.

In an application codebase, most slow queries trace to one of three causes: a missing or wrong index that forces a sequential scan, an N+1 pattern where the ORM fires one query per row, or connection-pool exhaustion under load. The fastest way to tell them apart is to look rather than guess. Run EXPLAIN ANALYZE on the query to see whether the planner is doing a sequential scan and removing a lot of rows by filter, count how many queries a single request actually fires to catch N+1, and check your connection metrics for saturation. A query that got slower over time without a code change is often a statistics or vacuum issue — a stale planner estimate or dead-row bloat — which running ANALYZE on the table frequently resolves.
Related dispatches

Continue exploring developer guides.