PERFORMANCE

Your API Is Slow. Your Database Probably Isn't the Problem.

By DharmOps TeamMay 19, 202610 min read
Debugging slow API performance with database query analysis and N+1 detection
On this page

When an API endpoint is slow, the database gets blamed immediately. Engineers add read replicas, upgrade the instance class, or throw caching in front of the problem — none of which helps if the database was never the bottleneck.

In our experience across hundreds of performance engagements, 80% of slow API endpoints trace to the application layer: N+1 query patterns from ORM misconfiguration, connection pool exhaustion preventing queries from starting, full result sets being transferred when three columns would do, or multiple serial database calls that could be issued in parallel. The database is often running its individual queries in 2–5ms.

The API response time is 800ms. The gap is entirely in the application.

This article gives you the diagnostic framework to find where the time actually goes, the four most common causes of application-layer database slowness, and the fixes for each.

Prefer to skip the profiling and have an expert find it? Book a free 30-min diagnostic →

Step 1: Measure Before You Guess — Isolate Where the Time Is

Before touching anything, instrument the request to find where the time actually goes. The goal is to separate total request time into: time spent waiting for a database connection, time spent executing database queries, time spent in application code between queries, and time spent serialising and sending the response. Most APM tools (Datadog APM, New Relic, Sentry Performance, OpenTelemetry) show distributed traces with database spans.

If you do not have APM, add timing instrumentation manually at the database call boundaries — log the time before and after each query execution, not just the total request duration. The pattern you are looking for: if individual query execution times are under 10ms but total request time is 800ms, the problem is not query performance. It is either the number of queries (N+1), the time waiting for a connection, or application processing between queries.

If one query execution time is 800ms, the problem is in that query — use EXPLAIN ANALYZE and the slow query log.

-- pg_stat_statements: find which queries your slow endpoint is running
-- Run this before and after a slow request to see what fired
SELECT
  calls,
  round(mean_exec_time::numeric, 2) AS avg_ms,
  round(total_exec_time::numeric, 2) AS total_ms,
  substring(query, 1, 100) AS query_short
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat%'
ORDER BY calls DESC
LIMIT 20;

-- If you see the same query hundreds of times in one minute: N+1 pattern
-- If one query shows avg_ms > 100: fix the query itself
-- If all queries are fast but count is high: N+1 or unnecessary fetching

Cause 1: N+1 Queries — The Silent Request Killer

The N+1 query pattern occurs when code fetches a list of N records and then issues one additional query per record to load related data. 5ms. 5ms per query = 100ms in database time + 100ms in network round-trip time + 100ms in connection overhead = 300ms from database operations alone.

Add application serialisation and the response reaches 600ms. customer triggers a SELECT query. In Django, this is an un-prefetched ForeignKey.

In Rails, an association without eager loading. In Prisma, a related model access without include. In all cases, the fix is the same: batch the relationship load using a single JOIN or a WHERE IN query.

).

-- Django: bad (N+1)
orders = Order.objects.filter(status='pending')
for order in orders:
    print(order.customer.name)  # triggers 1 query per order

-- Django: good (prefetch_related eliminates N+1)
orders = Order.objects.filter(status='pending').select_related('customer')

-- Prisma: bad (N+1)
const orders = await prisma.order.findMany({ where: { status: 'pending' } });
for (const order of orders) {
  const customer = await prisma.customer.findUnique({ where: { id: order.customerId } });
}

-- Prisma: good (include eliminates N+1)
const orders = await prisma.order.findMany({
  where: { status: 'pending' },
  include: { customer: true }
});

-- SQL: always prefer a single JOIN over N individual lookups
SELECT o.id, o.total, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'pending';

Related: our deep dive on the N+1 query problem, how to debug slow SQL queries

Cause 2: Connection Pool Exhaustion — Why Queries Appear Slow Without Being Slow

Connection pool exhaustion looks exactly like slow query execution in most monitoring tools. The application waits 300ms before a query runs. The query itself takes 5ms.

The total is 305ms — and 300ms of that is the application waiting for a free connection from the pool, not database execution. This pattern appears in APM traces as a long database span with a very short execution segment inside it. In pg_stat_activity, the connection enters the 'idle' state and then 'active' state after a delay — the delay being the wait for a pool slot.

Connection pool exhaustion happens when all pool connections are busy and new requests queue behind them. Common causes: a slow query holding a connection for hundreds of milliseconds, a transaction that is not committed promptly, or a Kubernetes deployment scale-up that outpaces the pool capacity. The fix is either increasing the pool size (if the database can handle more concurrent backends), adding PgBouncer to multiplex connections, or finding and fixing the slow query or uncommitted transaction that is holding connections too long.

-- Detect pool exhaustion: look for requests queuing in your pool
-- Node.js pg pool example: add event listeners
const pool = new Pool({ max: 20 });
pool.on('acquire', () => { /* connection acquired from pool */ });
pool.on('connect', () => { /* new connection created — pool was empty */ });

// Log pool stats to detect exhaustion
setInterval(() => {
  console.log({
    total: pool.totalCount,
    idle: pool.idleCount,
    waiting: pool.waitingCount  // non-zero = pool exhaustion occurring
  });
}, 5000);

-- PostgreSQL: find long-running transactions holding connections
SELECT
  pid,
  now() - xact_start AS transaction_age,
  state,
  application_name,
  substring(query, 1, 80) AS current_query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
  AND now() - xact_start > INTERVAL '30 seconds'
ORDER BY transaction_age DESC;

Related: fixing PostgreSQL connection exhaustion with PgBouncer

Cause 3: Fetching More Data Than the Endpoint Needs

SELECT * is the most common form of over-fetching, but it is not the only one. A user list endpoint that fetches every column including profile_picture_blob and settings_json — both large fields — transfers megabytes of data for a response that only displays name and email. The database executes the query fast.

The transfer time from database to application is slow. The serialisation of a 2 MB result set is slow. The JSON encoding of 200 full user objects is slow.

The fix is always to select only the columns the endpoint actually returns to the client, and to paginate result sets. select(), Prisma's select, Hibernate's projections. The secondary problem is fetching entire relationship trees when only one field from the related record is needed.

name requires loading the entire Customer record — address, phone, created_at, metadata — to display one field. Projections that fetch only the needed fields from joined tables are the solution. For large blobs and JSON columns, consider lazy loading or separate endpoints — load the lightweight list first, then fetch the heavy fields on demand.

-- Bad: SELECT * fetches all columns including large BLOBs
SELECT * FROM users WHERE status = 'active' LIMIT 100;

-- Good: select only what the API response needs
SELECT id, name, email, created_at
FROM users
WHERE status = 'active'
LIMIT 100;

-- Django: limit to specific fields
users = User.objects.filter(status='active').values('id', 'name', 'email')[:100]

-- Prisma: select specific fields
const users = await prisma.user.findMany({
  where: { status: 'active' },
  select: { id: true, name: true, email: true },
  take: 100
});

-- Always add LIMIT for list endpoints — unbounded queries are a ticking clock
-- A query returning 50 rows today may return 50,000 rows in 18 months

Cause 4: Serial Database Calls That Could Be Parallel

API endpoints frequently make multiple independent database queries in sequence when they could be issued in parallel. A user profile endpoint that fetches the user record, then their recent orders, then their notification preferences — three independent queries — runs them serially: 5ms + 5ms + 5ms = 15ms minimum, plus three round-trips. gather(), completing in approximately 5ms + one round-trip.

The pattern is more subtle with ORMs: accessing multiple relationship properties on a model often triggers serial queries even when the framework could batch them. The fix requires identifying which queries are independent (no data dependency between them) and issuing them concurrently. In GraphQL, the DataLoader pattern solves both N+1 and serial loading by batching multiple field-level data requests into a single query.

all() or async gather calls on independent data fetches is the straightforward solution.

// Node.js: bad — three serial queries, 15ms+
const user = await prisma.user.findUnique({ where: { id } });
const orders = await prisma.order.findMany({ where: { userId: id } });
const prefs = await prisma.preference.findUnique({ where: { userId: id } });

// Node.js: good — three parallel queries, ~5ms
const [user, orders, prefs] = await Promise.all([
  prisma.user.findUnique({ where: { id } }),
  prisma.order.findMany({ where: { userId: id }, take: 10 }),
  prisma.preference.findUnique({ where: { userId: id } })
]);

# Python: bad — serial
user = await db.fetch_one("SELECT * FROM users WHERE id = $1", user_id)
orders = await db.fetch_all("SELECT * FROM orders WHERE user_id = $1", user_id)

# Python: good — parallel
user, orders = await asyncio.gather(
    db.fetch_one("SELECT id, name FROM users WHERE id = $1", user_id),
    db.fetch_all("SELECT id, total FROM orders WHERE user_id = $1 LIMIT 10", user_id)
)

ORM-Specific Patterns That Kill Performance

Each major ORM has idiomatic patterns that appear clean in code but generate catastrophically inefficient SQL. count() in a loop instead of aggregating. where with a block (which fetches all records then filters in Ruby) is common.

In Prisma, the issue is accessing nested relations without include — each nested relation access triggers a separate query. In SQLAlchemy, lazy-loaded relationships and the use of ORM objects instead of query result tuples adds serialisation overhead. The single most impactful change in any ORM codebase is configuring strict N+1 detection in development.

Django has nplusone. Rails has bullet. These libraries detect and log N+1 patterns during test runs and development — making them visible before they reach production.

# Django: enable nplusone to catch N+1 in tests
# pip install nplusone
MIDDLEWARE = ['nplusone.ext.django.NPlusOneMiddleware']
NPLUSONE_RAISE = True  # raise exception in tests, not just log

# Rails: configure bullet in development
# gem 'bullet', group: :development
# config/environments/development.rb
Bullet.enable = true
Bullet.alert = true
Bullet.rails_logger = true
Bullet.add_footer = true  # shows N+1 alerts in page footer

# Node.js/Prisma: log all queries to find N+1 manually
const prisma = new PrismaClient({
  log: ['query'],  // log every query with timing
})

Related: our API performance service

Real Example: p99 from 4.2 Seconds to 180ms

2 seconds. The endpoint loaded a list of transactions with associated merchant details, category labels, and user notes. Adding a read replica had no effect.

2xlarge had no effect. Profiling the request with OpenTelemetry traces revealed: 187 database spans in a single request, each completing in 1–4ms, totalling approximately 400ms of database execution time. The remaining 3,800ms was round-trip latency and connection wait time.

The endpoint was fetching a list of 150 transactions (1 query) and then loading merchant details per transaction (150 queries), category labels per transaction (150 queries), and user notes per transaction (150 queries) — classic N+1 pattern, three times over. The fix was three select_related / include calls to load all relationship data in the initial transaction fetch, collapsing 451 queries into 4. We also switched from SELECT * to selecting only the 8 fields the mobile app actually displayed.

2 seconds to 180ms. The database instance remained unchanged. Total engineering time from profiling to production deploy: one day.

4.2 seconds to 180ms. One day. Zero infrastructure changes.

A senior DBA can profile your slowest endpoint and find the actual bottleneck — free, 30 minutes.

Book Free Diagnostic

When an API is slow, resist the instinct to add infrastructure before you have measured. The pattern of adding read replicas, cache layers, and larger instances before understanding the actual bottleneck is expensive and often ineffective. Start with a request trace that shows individual database spans with their execution times and counts.

If the query count is high relative to the record count in the response, you have an N+1 problem. If individual query times are fast but connection acquisition time is slow, you have pool exhaustion. If you are fetching large columns you do not use, you have an over-fetching problem.

If multiple queries run serially but have no data dependency, you have a parallelism opportunity. Each of these is fixable at the application layer with no infrastructure changes — and each fix compounds with the others. Fix the N+1, then the over-fetching, then parallelise the independent calls.

A request that made 300 database round-trips in 2 seconds becomes 4 database calls in 50ms.

Frequently Asked Questions

Why is my API slow if the database queries are fast?

When individual database queries complete in 1–5ms but API responses take 500ms or more, the problem is usually in the application layer: N+1 query patterns generating hundreds of database round-trips per request, connection pool exhaustion causing wait time before queries start, over-fetching (SELECT *) adding unnecessary data transfer and serialisation overhead, or multiple independent database calls being made serially instead of in parallel.

What is an N+1 query problem?

The N+1 query problem occurs when code fetches a list of N records and then issues one additional database query per record to load related data — producing N+1 round-trips instead of one. For example, loading 200 orders and then fetching each order's customer separately triggers 201 queries. The fix is to use eager loading: select_related() in Django, includes() in Rails, or include: { customer: true } in Prisma.

How do I detect N+1 queries in production?

Query pg_stat_statements and look for a query executing thousands of times per hour with near-identical structure but different parameter values — for example, 'SELECT * FROM customers WHERE id = $1' running 80,000 times in an hour. In development, use nplusone (Django) or bullet (Rails) to detect N+1 patterns before they reach production.

What is connection pool exhaustion and how does it slow down my API?

Connection pool exhaustion occurs when all pool connections are occupied and new requests queue waiting for one to become free. It appears as high API latency in APM tools even though the database queries themselves execute quickly — the time is spent waiting for a connection, not running the query. Monitor pool.waitingCount in Node.js or check pg_stat_activity for connections in the 'idle in transaction' state.

How do I fix N+1 queries in Django, Rails, and Prisma?

In Django: add select_related('field') for ForeignKey and prefetch_related('field') for ManyToMany. In Rails: add includes(:association) to the query chain. In Prisma: add include: { relationName: true } to findMany. In all cases, the ORM loads related data in the initial query instead of issuing one query per record during iteration. Enable nplusone or bullet in development to catch these before production.

Need Expert Database Guidance?

Book a free 30-minute diagnostic call. Whether you are debugging slow queries, evaluating databases, or planning a migration — we will give you specific, actionable recommendations, not generic advice.

BOOK DIAGNOSTIC

Related Articles

How to Debug Slow SQL Queries: A Step-by-Step Guide

February 18, 2026

How to Debug Slow SQL Queries: A Step-by-Step Guide

Learn how to find, diagnose, and fix slow SQL queries using EXPLAIN ANALYZE, slow query logs, and proven debugging techniques. Covers missing indexes, N+1 patterns, full table scans, and query rewriting — with real SQL examples throughout.

Read more
PostgreSQL vs MySQL in 2026: A Complete Guide to Choosing the Right Database

February 25, 2026

PostgreSQL vs MySQL in 2026: A Complete Guide to Choosing the Right Database

A complete PostgreSQL vs MySQL comparison for 2026. Performance benchmarks, JSON support, replication models, licensing, migration complexity, and a practical decision framework for CTOs, architects, and developers.

Read more
Database Backup and Recovery: A Complete Best Practices Guide

March 3, 2026

Database Backup and Recovery: A Complete Best Practices Guide

Everything you need to build a complete database backup strategy: full vs incremental vs differential backups, PITR setup, RTO/RPO planning, tool comparison (pg_basebackup, WAL-G, Barman), recovery testing procedures, and compliance retention requirements.

Read more
AWS RDS Performance Tuning: A Complete Optimization Guide

March 5, 2026

AWS RDS Performance Tuning: A Complete Optimization Guide

How to get maximum performance from AWS RDS: choosing the right instance class, tuning PostgreSQL parameter groups, configuring read replicas, optimising GP3 storage and IOPS, setting up CloudWatch and Performance Insights, cutting costs, and when to migrate to Aurora.

Read more
AWS RDS Extended Support Is Doubling in 2026 — Here's What You Owe and How to Stop It

May 12, 2026

AWS RDS Extended Support Is Doubling in 2026 — Here's What You Owe and How to Stop It

MySQL 5.7 and PostgreSQL 11 on RDS entered Extended Support Year 3 in 2026, and AWS doubled the rate on March 1. Most teams don't know what they're paying or how to calculate their exposure. This guide shows exactly what the charges are, how to find them in your bill, and the three upgrade paths that eliminate the cost.

Read more
PostgreSQL Connection Exhaustion: How to Fix 'Too Many Connections' with PgBouncer

May 16, 2026

PostgreSQL Connection Exhaustion: How to Fix 'Too Many Connections' with PgBouncer

When your application hits PostgreSQL's connection limit, every new connection attempt fails immediately. The database looks unhealthy, the app returns 500s, and engineers blame the database — but the database is usually fine. The problem is connection management. This guide covers why PostgreSQL's architecture creates this problem, how to diagnose it, and how to deploy PgBouncer to eliminate it.

Read more
PostgreSQL 18: Every Performance Improvement You Need to Know

May 22, 2026

PostgreSQL 18: Every Performance Improvement You Need to Know

PostgreSQL 18 entered public beta in April 2026 with meaningful performance gains: 20–30% short query throughput improvements, major vacuum and autovacuum changes, an enhanced query planner, reliable logical replication, and pgvector improvements for AI workloads. Here is what changed, what it means in practice, and how to test it before upgrading production.

Read more
What Is the N+1 Query Problem? (And Why It Kills APIs)

June 16, 2026

What Is the N+1 Query Problem? (And Why It Kills APIs)

Your API takes four seconds to load. The database CPU looks healthy. You add a read replica — nothing changes. You upgrade the instance — still slow. Someone profiles the request and finds one API call is generating 501 database queries. This is the N+1 query problem: one of the most common and expensive performance issues in production applications. It doesn't look like a bug, throws no errors, and hides inside clean ORM code. This guide covers what it is, why it happens, how to detect it in production, and five ways to fix it permanently.

Read more
pgvector vs Pinecone in 2026: Benchmarks, Cost, and When to Use Each

June 16, 2026

pgvector vs Pinecone in 2026: Benchmarks, Cost, and When to Use Each

Most teams evaluating vector search assume a serious AI application needs a dedicated vector database. That assumption is increasingly outdated. pgvector's HNSW indexing, PostgreSQL 18's throughput gains, and managed PostgreSQL offerings have shifted the decision boundary. This guide covers where the performance gap actually stands, cost at three workload sizes, the hybrid SQL+vector search advantage most comparisons miss, and the exact conditions where Pinecone becomes the right call.

Read more
The Database FinOps Guide: Stop Overpaying for Cloud Databases

June 16, 2026

The Database FinOps Guide: Stop Overpaying for Cloud Databases

Cloud database bills grow in ways that are hard to see until the number is already embarrassing. Overprovisioned instances, idle read replicas, storage you forgot was accumulating, queries running full table scans on every request — each one adds a line item that compounds quietly. This guide walks through what Database FinOps actually means, the seven biggest sources of database waste, the four-step FinOps process, and a realistic SaaS example that cut $36,000 per year without touching the application code.

Read more
Managed DBA vs. Hiring In-House: The Full Cost Breakdown for 2026

May 26, 2026

Managed DBA vs. Hiring In-House: The Full Cost Breakdown for 2026

Most engineering leaders underestimate the true cost of an in-house DBA by 40–60% because they think salary, not total cost. This guide breaks down the full economics of both models, the scenarios where each wins, and a side-by-side comparison across four company sizes — from a 15-person startup to a 400-person scale-up.

Read more
Why Is My AWS RDS Bill So High? A Diagnostic Guide

June 16, 2026

Why Is My AWS RDS Bill So High? A Diagnostic Guide

Your AWS RDS bill is high for a reason — usually several. This guide covers the seven most common causes: overprovisioned compute, forgotten read replicas, inefficient queries, storage waste, gp2 storage, on-demand pricing, and solving the wrong problem entirely. Includes CloudWatch diagnostic commands, pg_stat_statements queries, and a 15-minute audit checklist.

Read more