Database Query Profiling: Systematic Optimization Journey
Systematic profiling for PostgreSQL and MongoDB: how to find the queries driving latency and infrastructure cost, and which fixes actually move them.
Slow database queries are the most common source of runaway infrastructure costs in SaaS products: product searches taking 8 seconds and dashboards stalling for 45 seconds are symptoms, not root causes. Adding servers suppresses the symptoms while compounding the bill. Profile first, and scale only the part that profiling cannot explain. On PostgreSQL that means pg_stat_statements plus EXPLAIN; on MongoDB it means the database profiler plus an explain plan on the pipeline.
Symptoms in a Multi-Tenant SaaS
Consider a multi-tenant SaaS platform running both PostgreSQL (for transactional data) and MongoDB (for analytics and document storage). On paper the architecture looks solid. In practice, product search takes 8 seconds per query during peak traffic, and dashboard analytics queries take 45 seconds or more to load, which makes the feature unusable in front of a customer.
Both numbers are symptoms. They say a request is slow. They say nothing about which plan, which index, or which collection scan produced the delay, and that answer only comes from instrumenting the databases themselves.
The Failed MongoDB Migration
A common failure mode: migrating a core product catalog from PostgreSQL to MongoDB under the assumption that NoSQL handles scale better and document storage fits API responses naturally.
A few months in, with a visibly larger AWS bill, the pattern reveals itself. SQL thinking carried into a NoSQL world means complex joins executed in application memory instead of in MongoDB aggregation pipelines, and query performance ends up worse than it was before the move.
Database technology migrations don’t magically fix poorly designed queries. Understanding access patterns first, then choosing the right tool, avoids this expensive detour.
The Profiling Setup
The next step is instrumenting both engines so that slow work gets recorded instead of guessed at.
PostgreSQL Profiling Stack
Enabling detailed query logging captures everything taking more than 100ms:
-- Enable comprehensive query logging
ALTER SYSTEM SET log_min_duration_statement = 100;
ALTER SYSTEM SET log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h ';
ALTER SYSTEM SET log_checkpoints = on;
ALTER SYSTEM SET log_connections = on;
ALTER SYSTEM SET log_disconnections = on;
ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();
Then pg_stat_statements reveals the real picture:
-- Find the resource hogs
SELECT
substring(query, 1, 100) as query_start,
calls,
total_exec_time,
mean_exec_time,
rows,
100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0) AS cache_hit_percent
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
On PostgreSQL 12 and earlier those columns are named total_time and mean_time; the rename landed with pg_stat_statements 1.8.
Ranking by total time rather than by worst single call is what makes this list useful. The top entry is usually a query that looks harmless in isolation: a few hundred milliseconds, called tens of thousands of times, with a cache hit ratio low enough to show that nearly every call reaches storage. In the catalog case it is a sequential scan over a 50-million-row products table, triggered every time someone filters by category.
MongoDB Profiling Configuration
For MongoDB, enabling the profiler captures operations over 100ms:
// Record operations slower than 100ms (level 1; level 2 records everything)
db.setProfilingLevel(1, { slowms: 100 });
// Analyze the patterns
db.system.profile.aggregate([
{ $match: { ns: "myapp.products" } },
{ $group: {
_id: "$command.find",
count: { $sum: 1 },
avgDuration: { $avg: "$millis" },
maxDuration: { $max: "$millis" },
totalDuration: { $sum: "$millis" }
}
},
{ $sort: { totalDuration: -1 } },
{ $limit: 10 }
]);
Grouping by total duration usually surfaces one pipeline that dominates everything else. The culprit is typically an application-level join: documents pulled into the service and merged there instead of in MongoDB’s aggregation framework.
The Missing Composite Index
During peak shopping traffic, product search performance degrades badly, and users abandon carts because results take 8 seconds or more to load.
PostgreSQL logs point at the root cause:
-- The killer query (simplified)
SELECT p.*, c.name as category_name
FROM products p
JOIN categories c ON p.category_id = c.id
WHERE p.active = true
AND p.category_id = $1
AND p.price BETWEEN $2 AND $3
ORDER BY p.rating DESC, p.created_at DESC;
The EXPLAIN plan shows a sequential scan across 50 million products for every search. Separate indexes on category_id and price exist, but not the composite index this filter combination needs.
CREATE INDEX CONCURRENTLY builds the index without blocking writes, so no maintenance window is required. It cannot run inside a transaction block, and a failed build leaves an invalid index behind that has to be dropped:
CREATE INDEX CONCURRENTLY idx_products_category_price_rating
ON products (category_id, price, rating DESC, created_at DESC)
WHERE active = true;
Search time drops from seconds to the low hundreds of milliseconds, because the planner reads only the matching slice of the index instead of scanning 50 million rows. The sort stays in the plan: price is a range predicate, so the index returns its rows ordered by price before rating, and PostgreSQL still sorts what comes back. That sort is cheap once its input is a few thousand rows instead of 50 million. Removing it altogether takes a different column order, (category_id, rating DESC, created_at DESC, price), because only equality predicates may precede the sort columns. That shape reads every rating in the category and filters price row by row, so it earns its keep mainly when the query carries a LIMIT.
The trade-off sits on the write path. Every product insert or update now maintains one more index, and the partial index still pays that cost whenever the active flag or any indexed column changes. Measure the write latency delta on your own workload before shipping the index; a read win only counts if the write budget absorbs it.
Moving Joins into the Aggregation Pipeline
PostgreSQL is only half the picture. Analytics on the MongoDB side often stays slow after the SQL side is fixed, and a dashboard that needs 30 seconds or more to load is one users abandon.
The root cause is often how the application thinks about data. A Node.js application doing this:
// The wrong way - application-level joins
const users = await User.find({ active: true });
const userIds = users.map(u => u._id);
const orders = await Order.find({ userId: { $in: userIds } });
const analytics = users.map(user => {
const userOrders = orders.filter(o => o.userId === user._id);
const totalRevenue = userOrders.reduce((sum, o) => sum + o.total, 0);
return {
userId: user._id,
totalRevenue,
orderCount: userOrders.length,
avgOrderValue: userOrders.length > 0 ? totalRevenue / userOrders.length : 0
};
});
That loads potentially millions of documents into application memory and joins them in JavaScript, so the cost scales with the size of the collections rather than with the size of the result.
Running the same work in the aggregation pipeline keeps it next to the data:
// The right way - database-level aggregation
const analytics = await User.aggregate([
{ $match: { active: true } },
{ $lookup: {
from: "orders",
localField: "_id",
foreignField: "userId",
as: "orders"
}
},
{ $project: {
userId: "$_id",
totalRevenue: { $sum: "$orders.total" },
orderCount: { $size: "$orders" },
avgOrderValue: {
$cond: [
{ $gt: [{ $size: "$orders" }, 0] },
{ $divide: [{ $sum: "$orders.total" }, { $size: "$orders" }] },
0
]
}
}
}
]);
Only the projected fields cross the wire, and the join happens where the indexes are. Index the foreign field first: without an index on orders.userId, $lookup degrades to a scan per input document and the pipeline version can be slower than the code it replaced. Watch the 100MB per-stage memory limit too; allowDiskUse keeps a wide $lookup from failing outright, at the price of spilling to disk.
Continuous Performance Monitoring
A comprehensive monitoring system helps catch performance regressions before they impact users:
interface DatabaseMetrics {
postgresql: {
activeConnections: number;
queryDuration: PercentileMetrics;
cacheHitRatio: number;
indexUsage: IndexEfficiency[];
lockWaitTimes: Duration[];
};
mongodb: {
operationCounts: OperationType[];
queryExecutionStats: ExecutionStats;
indexEffectiveness: IndexMetrics[];
shardingBalance: ShardDistribution;
};
infrastructure: {
cpuUtilization: number;
memoryUsage: MemoryMetrics;
diskIOPS: IOMetrics;
networkLatency: NetworkStats;
};
}
// Custom query performance tracker
class QueryPerformanceTracker {
private metrics = new Map<string, QueryMetrics>();
async trackQuery(query: string, duration: number, database: 'postgres' | 'mongodb') {
const querySignature = this.normalizeQuery(query);
const existing = this.metrics.get(querySignature) || {
count: 0,
totalDuration: 0,
maxDuration: 0,
database
};
// Compare against the baseline before folding the new sample in
const previousMax = existing.maxDuration;
existing.count++;
existing.totalDuration += duration;
existing.maxDuration = Math.max(previousMax, duration);
this.metrics.set(querySignature, existing);
// Alert on regression
if (previousMax > 0 && duration > previousMax * 1.5) {
await this.alertPerformanceRegression(querySignature, duration);
}
}
}
The value sits in the signature. Normalizing parameters out of the query text makes the same statement comparable across calls, so an unindexed variant introduced in a pull request shows up as a shifted distribution rather than as one slow request buried in the logs. Comparing against a stored baseline, instead of against the running maximum, is what keeps the check from silencing itself as the worst case creeps upward.
The Economics of Database Optimization
Query work is why most database bills grow. A sequential scan burns IOPS and evicts useful pages from the buffer cache, a low cache hit ratio pushes reads back to storage, and both push the instance class up.
Removing the scan reverses that chain in a specific order. CPU and IO drop first. Read replicas that exist only to absorb query load become removable next. The instance class and the Atlas tier come down last, because resizing is a step function tied to a maintenance action rather than something that follows the load down continuously. Measure at least one full weekly traffic cycle after the query fix before resizing anything, or you size against a load profile that no longer exists.
PostgreSQL vs MongoDB: Choosing the Right Tool
Experience with both systems reveals clear patterns for when to use each:
Use PostgreSQL when:
- You need ACID compliance (financial transactions, inventory management)
- Complex JOIN operations are common in your queries
- You want predictable performance characteristics
- Your team is more familiar with SQL than NoSQL concepts
- Data consistency is more important than eventual consistency
Use MongoDB when:
- You need horizontal scaling built-in
- Your data model frequently changes (startup pivots, rapid iteration)
- You’re doing complex aggregations on large datasets
- Document-based queries match your application objects
- You need excellent read performance with acceptable write latency
When the answer is not obvious, default to PostgreSQL. It covers document workloads with jsonb well enough for most product catalogs, and moving a collection out of PostgreSQL later is a smaller change than moving relational data into it after the fact. Base the decision on access patterns and consistency requirements.
Effective Profiling Tools
Evaluating database profiling tools reveals which ones deliver real value:
PostgreSQL Tools
pgBadger for Log Analysis:
# Generate comprehensive performance reports
pgbadger -j 4 -f stderr /var/log/postgresql/postgresql-*.log \
--prefix '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h' \
-o /var/www/html/pgbadger.html
pgBadger turns PostgreSQL logs into actionable reports, surfacing patterns like queries that are fast individually but consume significant total time due to call frequency.
Percona Monitoring and Management (PMM): PMM’s Query Analytics keeps per-query time series with the plan attached, which is what separates a genuine regression from a traffic change. It covers both PostgreSQL and MongoDB, so one dashboard answers questions about both engines.
MongoDB Tools
MongoDB Compass: Compass renders explain output as a tree, which makes a collection scan or an in-memory sort easy to spot without reading raw JSON. Checking a pipeline there before shipping it costs a minute.
Custom Profiling Scripts:
// Automated slow query detection
function analyzeSlowQueries() {
return db.system.profile.aggregate([
{ $match: { millis: { $gt: 100 } } },
{ $group: {
_id: {
collection: "$ns",
operation: "$op"
},
count: { $sum: 1 },
avgDuration: { $avg: "$millis" },
maxDuration: { $max: "$millis" }
}
},
{ $sort: { avgDuration: -1 } }
]);
}
Scheduled daily, this reports the slowest operations of the previous window. Note that system.profile is a capped collection: whatever is not read before it wraps is gone, so export the summary rather than relying on the raw entries staying around.
Common Pitfalls and How to Avoid Them
The “Add More Indexes” Trap
Treating indexes as a silver bullet is the common way this goes wrong: query slow, add an index; still slow, add another. Before long a hot table carries a dozen indexes, every insert maintains all of them, and the write path costs more than the reads ever saved.
Every index speeds up reads but slows down writes. Design the indexing strategy around the read/write ratio of each table, and be selective on write-heavy ones. pg_stat_user_indexes shows which indexes are never scanned; those are pure cost.
The Production Data Surprise
An optimization that works against 10,000 uniform test records says little about 50 million production records with a skewed distribution.
The planner picks a plan from statistics. When one tenant holds most of the rows, or a status column is 99% one value, the row estimates are wrong and the plan is wrong with them, so a query that runs in 10ms locally can take orders of magnitude longer in production.
Test optimizations against production-scale data, or at least against data with similar distribution characteristics. Restoring a sanitized dump and running ANALYZE gets closer than any synthetic generator.
The Wrong Metrics Focus
A recurring failure mode: optimizing the wrong queries by measuring database-centric metrics instead of user-facing operations. Weeks spent on overnight batch processes while user-facing search queries remain slow.
Focus on operations that directly impact user experience first. Internal reporting can be slow; user-facing operations cannot.
Recommended Practices
Start with Business Impact Measurement Instead of optimizing the slowest queries first, prioritize queries with the highest business impact. A 1-second improvement on a query hit 10,000 times per day delivers more value than a 10-second improvement on a query hit once per day.
Implement Automated Performance Testing Set up automated performance regression testing in your CI/CD pipeline. Catch performance issues during code review, not after customer complaints.
Invest in Team Education Early Train your entire development team on database performance fundamentals before starting optimization. Every developer writing queries should understand EXPLAIN plans and basic indexing strategies.
Plan for 10x Data Growth Design your optimization strategies to work at 10x your current data volume. Today’s fast query becomes tomorrow’s timeout if you don’t plan for growth.
When This Approach Holds
Profiling before scaling holds when the workload is query-bound: the instance is busy, but the time goes into plans nobody has read. Scale the hardware instead when the evidence points the other way. A workload whose top queries already use their indexes, whose cache hit ratio is high, and which still saturates IOPS has exhausted what EXPLAIN can give it and needs a bigger instance. The same is true of a write-heavy table where index maintenance is already the bottleneck; there the next move is dropping indexes or partitioning the table.
The starting point is identical in both cases. Enable pg_stat_statements and the MongoDB profiler, rank by total time rather than by worst single call, and fix the top two entries before changing anything else.
References
- Using EXPLAIN - PostgreSQL Documentation - Official PostgreSQL guide to interpreting EXPLAIN output and query plan optimization
- EXPLAIN Command Reference - PostgreSQL - Full syntax and options for the PostgreSQL EXPLAIN command including ANALYZE and BUFFERS
- pg_stat_statements - PostgreSQL Documentation - PostgreSQL extension for tracking planning and execution statistics of all SQL statements
- auto_explain - PostgreSQL Documentation - PostgreSQL module for automatically logging execution plans of slow queries
- Use The Index, Luke - Free e-book on SQL indexing and performance tuning covering PostgreSQL, MySQL, Oracle, and SQL Server
- Database Profiler - MongoDB Manual - Profiling levels, the slowms threshold, and how to query the capped system.profile collection
- Aggregation Pipeline - MongoDB Manual - Stage reference for running joins, grouping, and projections inside the database
- Analyze Query Performance - MongoDB Manual - Reading explain output to confirm whether a query or pipeline stage uses an index
- pgBadger - PostgreSQL log analyzer that turns log_min_duration_statement output into ranked query reports
Related posts
Lessons from running LangChain in production: the anti-patterns that cause failures, the patterns that work, with code examples and cost optimization strategies.
Multi-environment deployment, performance optimization at scale, cost management, and monitoring with solid incident response patterns.
Choose the right database across SQL, NoSQL, NewSQL, and edge options: the trade-offs of each category, selection criteria, and a decision framework.
Step-by-step guide to adding Sentry to a React Native Expo app: SDK setup, Expo Router instrumentation, session replay, and source maps for EAS.
Strategies to prevent and handle DynamoDB throttling in Single Table Design: partition key design, write sharding, capacity modes, DAX, and retry patterns.