Note
Access to this page requires authorization. You can try signing in or changing directories.
Access to this page requires authorization. You can try changing directories.
High CPU is a symptom with many possible causes, and the expensive mistake is guessing. Scaling up a server whose real problem is a missing index buys a few weeks and a larger bill. The CPU guide exists to identify which cause you actually have before you spend anything.
Symptoms this guide explains
- CPU utilization sustained near 100%, or spiking on a schedule
- Query latency degrading while the workload looks unchanged
- A Burstable server that performs well for a while, then abruptly slows
- CPU that stays high even when application traffic drops
Before you start
Enable PostgreSQL Server Logs, Sessions data, Query Store Runtime, and AllMetrics;
set pg_qs.query_capture_mode to TOP or ALL; and enable
metrics.collector_database_activity.
For the Locking and blocking tab, also set log_lock_waits to on.
Full steps are in Use the troubleshooting guides.
How this guide is organized
| Tab | The question it answers |
|---|---|
| CPU | Is CPU genuinely elevated, and exactly when? |
| Workload | Did the amount of work increase? |
| Transactions | Did transaction throughput increase? |
| Long running transactions | Is a single session holding resources? |
| Queries | Which statements consume the most CPU time? |
| User connections | Is this a connection storm rather than a query problem? |
| Locking and blocking | Are sessions burning time contending for locks? |
| Waits | What is execution actually blocked on? |
| Logs | Is the server spending CPU writing its own logs? |
| Insights | What anomalies were detected automatically? |
Walkthrough: CPU at 95% with no obvious workload change
A worked example of the intended path through the tabs.
Confirm the window. The CPU tab shows utilization climbing to 95% at 09:15 and holding. You narrow the range to 09:00 through 10:00 so later tabs rank against the incident rather than the whole day.
Check whether work increased. The Workload tab shows read and write tuple activity flat across 09:15. The server isn't doing more work. It's doing the same work less efficiently. This observation rules out "we simply got busier" and redirects you to efficiency causes.
Check throughput. Transactions confirms TPS is unchanged, reinforcing the same conclusion.
Look for a single culprit. Long running transactions shows one PID with a transaction open since 09:14, a minute before the spike. That timing is suspicious enough to note, but a single long transaction rarely saturates CPU by itself, so you keep going.
Rank the queries. The Queries tab, sorted by total execution duration, shows one query ID accounting for most of the window's CPU time. Its call count is normal but its mean duration jumped roughly tenfold compared with earlier buckets. A query that got slower without being called more often points at the data or the plan, not the traffic.
Confirm the mechanism. The Waits tab shows the dominant wait is I/O related rather than lock related, consistent with a plan that switched from an index scan to a sequential scan, which burns both CPU and I/O.
Act. You retrieve the SQL text, run
EXPLAIN (ANALYZE, BUFFERS), and confirm a sequential scan on a table that grew heavily bloated. A one-offVACUUM (ANALYZE, VERBOSE)restores the plan, and CPU returns to baseline. Because bloat was the root cause, you then open Monitor autovacuum to find out why autovacuum wasn't keeping up. Otherwise it recurs.
The shape of that investigation generalizes: confirm, rule out workload growth, attribute to a query or session, identify the mechanism, fix the root cause rather than the symptom.
Tab reference
CPU
The chart shows the maximum percentage of CPU in use, not the average. A single brief spike renders at full height, so a jagged line doesn't necessarily mean sustained pressure. Read the shape, not just the peak.
High CPU on its own isn't a problem. A server at 100% with acceptable query latency is a server you're getting your money's worth from. It becomes a problem when it's accompanied by latency, timeouts, or connection queuing. Confirm you have a real symptom before spending time here.
Reading the shape. The pattern tells you which tab to open next:
| Pattern | Usually means | Go to |
|---|---|---|
| Sustained plateau near 100% | The server is genuinely saturated | Workload first, to establish whether load grew |
| Regular spikes on a schedule | A batch job, cron task, or scheduled report | Queries, narrowed to one spike window |
| Step change that persists | A deployment, a plan regression, or a parameter change | Queries, comparing buckets before and after the step |
| Sawtooth, climbing then dropping sharply | Often checkpoint or vacuum activity | Waits, then Troubleshoot high IOPS utilization |
| Spiky with no pattern, latency fine | Normal bursty workload | Probably nothing. Verify latency is acceptable and stop. |
| High but flat while workload is flat | Efficiency loss, not load growth | Queries and Locking and blocking |
Pin the window before moving on. Note where the elevation starts and ends, then narrow the time range to roughly that period. Every later tab ranks against the selected range, so investigating a fifteen-minute incident inside a four-hour window buries the culprit under normal traffic. Remember the one-hour minimum: if the event was shorter, you'll still get an hour, so account for the surrounding baseline when reading the query rankings.
Burstable tier
Two extra metrics appear on this tier, and they change how you interpret everything else:
- CPU credits consumed: credits spent when the server runs above its baseline. A server with a 20% baseline running at 80% is spending credits continuously.
- CPU credits remaining: credits banked for future bursts. They accumulate while below baseline and deplete above it.
If your workload sits above baseline and remaining credits reach zero, the server is throttled to its baseline. This condition is the most commonly misdiagnosed performance problem on Burstable: nothing is wrong with your queries, you've simply exhausted the tier's budget. Check remaining credits before investigating anything else, because a throttled server produces slow queries, lock waits, and connection pileups that all look like independent problems.
High consumption with healthy remaining credits is the tier working as intended.
Burstable suits low-duty and dev/test workloads. If credits run out regularly, move to General Purpose. No amount of query tuning changes the baseline.
Workload
Split into Read workload and Write workload views. This tab separates "more work" from "less efficient work." Understand what the two read metrics count, because they aren't interchangeable:
| Metric | Counts |
|---|---|
tup_returned |
Live rows fetched by sequential scans, plus index entries returned by index scans |
tup_fetched |
Live rows fetched by index scans only |
The ratio between them is the useful signal. When tup_returned runs far above tup_fetched,
the server reads many rows to produce comparatively few useful ones. This pattern indicates
sequential scans over large tables. This pattern burns CPU proportional to table size rather than to
result size, and it's exactly what bloat or a missing index produces. When the two track closely,
reads are largely index-driven.
Write workload counts tuples inserted, updated, and deleted. Updates matter twice over: they cost CPU now and generate the dead tuples that create bloat later.
Both views exclude the system databases azure_sys and azure_maintenance, so figures reflect your
workload rather than platform overhead.
How to read it against the CPU curve:
- Workload rises with CPU. The CPU is doing real work. Go to Queries and User connections.
- Workload flat while CPU climbs. Efficiency loss. Suspect bloat, a plan regression, lock contention, or logging.
- Read workload flat but
tup_returneddiverging upward. Scans are getting less selective even though request volume is unchanged. Strong bloat or stale statistics signal.
Write workload detail isn't available on read replicas.
Transactions
Two views: Transaction trend and Transactions per second. Both need
metrics.collector_database_activity.
Transaction trend plots transactions committed against transactions rolled back. Don't skip the rollback series. A rollback rate that climbs with CPU usually means the application is failing and retrying, so the server is paying full execution cost for work that gets thrown away. That looks identical to a workload increase on a TPS chart alone, but the fix is entirely different: you're looking for the error, not tuning the query.
Transactions per second breaks throughput down per database, which tells you whether a spike is server-wide or confined to one tenant or application.
A TPS peak correlating with the CPU rise points to genuine workload growth. From there:
- Use Queries to see whether specific statements dominate. Inefficient queries at high TPS amplify quickly.
- Use User connections to distinguish real traffic growth from a retry storm.
- Consider pooling if the pattern is many short-lived connections.
- Scale up if the increase is legitimate and sustained.
Long running transactions
Any transaction running longer than the guide's threshold. Two durations are plotted separately, and the distinction matters:
- Connection duration (
collection_time - backend_start): how long the client has been connected. High values are normal with pooling and aren't themselves a problem. - Transaction duration (
collection_time - xact_start): how long the current transaction has been open. This is the number that indicates trouble.
The guide raises a banner when it detects sessions in the idle in transaction state, and it names
the specific PIDs. Treat that banner as a finding, not a warning. A session in this state does no
work while holding locks, occupying a connection slot, and pinning the xmin horizon so dead tuples
can't be cleaned up. It's a frequent root cause of problems that surface somewhere else entirely,
including the bloat that brought you to this guide.
Immediate mitigation. Cancel the running statement first. It's the less disruptive of the two options, because the session and its transaction survive:
SELECT pg_cancel_backend(<pid>);
If the session is idle in a transaction, or canceling doesn't release the resource, terminate the whole backend:
SELECT pg_terminate_backend(<pid>);
Caution
Terminating a backend rolls back its open transaction. Confirm what the session is doing before you end it. The PID might belong to a long-running migration or a batch job that restarts from the beginning.
Guardrails so it doesn't recur. Set limits on how long sessions and transactions can run:
| Parameter | What it bounds | Notes |
|---|---|---|
statement_timeout |
A single statement | The broadest safety net. Set per role or per session rather than server-wide if some jobs legitimately run long. |
idle_in_transaction_session_timeout |
A session sitting idle with an open transaction | Defaults to 0 (never). This setting most often prevents blocked cleanup. 300000 (5 minutes) is a common starting point. |
idle_session_timeout |
A session idle with no transaction | PostgreSQL 14 and later. Reclaims connection slots from abandoned clients. |
transaction_timeout |
Total transaction duration | PostgreSQL 17 and later. |
Queries
Query Store drives this tab. It needs pg_qs.query_capture_mode set to TOP or ALL.
The tab ranks queries three ways, because "expensive" has more than one meaning:
- By mean duration: individually slow queries.
- By total duration: the real CPU consumers over the window. A 50 ms query called 100,000 times outranks a 10-second query called twice.
- By calls: high-frequency statements worth caching or batching.
Always check total duration, not just mean. Death by a thousand cuts is more common than a single catastrophic query, and only the total ranking exposes it.
Select a query ID for per-bucket history. Comparing mean duration across buckets tells you whether a query has become slow, usually from a plan or data change, or was always slow and merely became more frequent.
- Run index tuning to get index recommendations based on your actual workload. This step is the most effective long-term fix.
- Run
EXPLAIN (ANALYZE, BUFFERS)on the query to see where time and I/O actually go. Look for sequential scans on large tables, nested loops over big row counts, and sorts that spill to disk. - Reduce table bloat. As a one-time step, run
VACUUM (ANALYZE, VERBOSE) <table_name>;. If bloat keeps returning, the cause is upstream. See Monitor autovacuum. - Add the indexes the plan needs, and remove joins that don't narrow the result.
- Partition large tables that are consistently hot.
- Revisit table design: index foreign keys in child tables, drop unused indexes (they cost on every write), and disable triggers during bulk loads.
Also tune max_connections and max_parallel_workers_per_gather with care. Both look like
throughput settings and both consume CPU when set too high. Parallel workers multiply CPU use per
query.
On a read replica
Query Store is supported on read replicas and remains the primary source for this tab. Enable it as
you would on a primary, with pg_qs.query_capture_mode set to TOP or ALL.
Native logging is an alternative view of the same queries. You can use it instead of, or alongside, Query Store. It needs two parameters set on the replica:
log_line_prefixset totime=%t, session=%c, pid=%p, user=%u, db=%d, client=%h, app=%aexactly, including the trailing space. The guide parses these named fields out of the log lines, so a different prefix leaves the log-based charts empty even if it carries the same information.log_min_duration_statementset to a threshold in milliseconds.
Caution
Don't set log_min_duration_statement to 0. Logging every query can generate enough volume to
cause query failures. If you hit this condition, raise the execution duration filter in the guide, or raise
the parameter. See the Logs tab, where log volume shows up as a CPU cost in its own right.
Tuning differs on a replica too. You can't run VACUUM, so ensure autovacuum keeps up on the
primary. Indexes created on the primary replicate automatically. Raise
max_standby_streaming_delay if you see canceling statement due to conflict with recovery, and
use hot_standby_feedback knowing it reduces replica cancellations at the cost of bloat on the
primary.
User connections
Requires the PostgreSQL Sessions data log category. Three views:
- Connections by state: the distribution across active, idle,
idle in transaction, and so on. A largeidle in transactionpopulation here supports what the Long running transactions tab showed. - Connections by duration: short (under 1 second), normal (1 second to 20 minutes), and long (over 20 minutes), derived from disconnection messages in the logs.
- PgBouncer connections statistics: total pooled connections and number of connection pools, when PgBouncer is enabled.
A large population of short connections indicates an application opening a connection per request. Each one costs a process fork and backend setup before any query runs, and at high rates that setup cost alone can dominate CPU.
The PgBouncer view turns a suspicion into a diagnosis. Comparing pooled connections against the number of pools tells you whether pools are sized appropriately. Reading it alongside Connections by state shows whether the pooler is actually reducing backend count or simply relaying every client straight through.
Every PostgreSQL connection is a separate operating-system process with its own memory. Hundreds of mostly idle connections use real CPU and memory before running a single query, so pooling is usually a bigger win than scaling up.
PgBouncer in Azure Database for PostgreSQL flexible server is built into Azure Database for PostgreSQL flexible server. Enable it with pgbouncer.enabled, and enable metrics.pgbouncer_diagnostics to get its metrics. Both are dynamic and need no restart. PgBouncer isn't supported on the Burstable tier.
Sizing the pool:
| Setting | Guidance |
|---|---|
pool_mode |
Use transaction for high-throughput workloads made of many short transactions, since it gives far better connection reuse. Use session when you need features that span a connection, such as prepared statements, advisory locks, or LISTEN/NOTIFY. |
default_pool_size |
Applies per user/database pair, not per server. Add up every pool you expect and keep the total safely under max_connections. |
server_idle_timeout |
How long an unused server connection is kept. Lower it to release idle backends sooner. |
query_timeout |
Bounds a single query through the pooler. |
Two failure modes worth recognizing:
- Pool exhaustion. Active server connections sit at
default_pool_sizewhile waiting clients climb. Either raisedefault_pool_size, tune the slow queries that hold connections, or move fromsessiontotransactionmode. - PgBouncer saturation. PgBouncer is single-threaded, so it uses one core no matter how large the server is. The signature is latency on port 6432 that's worse than connecting directly on 5432, with waiting clients growing while active server connections stay below
default_pool_size. Scale out across multiple PgBouncer instances on separate VMs, or evaluate a multithreaded pooler such as PgCat.
Locking and blocking
Requires log_lock_waits and metrics.collector_database_activity. PostgreSQL logs a message when
a session waits longer than deadlock_timeout, one second by default, to acquire a lock.
Four sections:
- Sessions by lock wait event: how many sessions were waiting on a
Lockwait event. - Duration of acquired locks by lock type: how long locks were held once acquired. Sessions that disconnected before acquiring their lock don't appear.
- Overview of waiting and acquired locks: both together, with durations.
- Blocking information: sessions still waiting at the end of the window.
Lock contention inflates CPU indirectly: waiting sessions still hold connections and re-check frequently, and the work they were doing gets retried or serialized.
Waits
Requires pgms_wait_sampling and the wait statistics diagnostic category. High CPU almost always
has an underlying wait pattern.
- Filter by WaitEventType, then drill into a specific WaitEvent.
- Enable Hide non-actionable waits to remove
ClientandActivityevents. These events represent the server waiting on someone else and rarely indicate server-side contention. - Switch View between
SummaryandTop queries per wait. - Select any row to read what the event means, its typical causes, and recommended actions.
Tip
Enable pg_qs.emit_query_text and route the PostgreSQLQueryStoreSqlText category to see SQL
text next to wait events instead of bare query IDs.
Logs
Logs are easy to overlook but can provide valuable information. PostgreSQL writes log lines on the same backend process that runs the query, synchronously. Verbose logging competes directly with query execution for CPU and I/O. This tab shows log volume over time, broken down by the parameter most likely responsible, with the total overlaid. Compare this data against the CPU tab: sustained log bursts aligned with CPU peaks help you identify both the problem and the parameter to change.
| Setting | Recommended | Why it matters |
|---|---|---|
log_min_duration_statement |
-1, or >= 1000 ms |
Values between 0 and 999 ms generate enormous volume on OLTP workloads. |
log_statement |
none or ddl |
mod and all log every modifying or every statement. |
log_statement_stats |
off |
Per-statement resource stats. Use only for short, deliberate tuning sessions. |
pgaudit.log |
none, or ddl,role |
Auditing READ/WRITE/ALL logs every matching statement synchronously. |
pgaudit.role |
Unset unless object auditing is required | Object-level auditing logs every read and write on that role's objects. |
Prefer Query store in Azure Database for PostgreSQL flexible server over log_min_duration_statement for ongoing query visibility. It has lower overhead, and it retains history you can analyze. If you must log slow statements, pair log_min_duration_sample with log_statement_sample_rate to keep a representative sample at a fraction of the volume.
Insights
This tab shows automatically detected anomalies for the window: connection surges, notable wait events, and log message anomalies ranked by combined volume and spike score. An empty Insights tab suggests the server was healthy in that period. Allow a few moments after opening for it to finish loading.
What good looks like
| Signal | Healthy | Investigate |
|---|---|---|
| CPU utilization | Headroom at peak | Sustained above 90% |
| Burstable credits remaining | Stable or recovering | Trending to zero |
| Workload vs CPU | Move together | CPU rises while workload is flat |
| Top query share of total duration | Spread across many queries | One or two queries dominate |
| Connection mix | Mostly normal-duration | Large short-connection population |
| Lock waits | Rare | Recurring, or sessions still blocked at window end |
| Log volume | Steady and low | Bursts aligned with CPU peaks |
What this guide can't tell you
- Client-side cost. CPU consumed by your application, ORM, or network layer is invisible here.
- Sub-sample events. Sessions and waits are sampled. A 200 ms lock storm between samples leaves no trace.
- Why a plan changed. The guide shows a query got slower; it doesn't show the plan diff. Use
EXPLAIN (ANALYZE, BUFFERS)and check whether statistics are stale. - Per-role and per-session overrides. Parameter values shown are server-level. A role with its own
work_memwon't be reflected. - Whether scaling will help. If the cause is one unindexed query, a larger SKU only raises the ceiling it's wasting.