Troubleshoot anomalies in CPU usage
View as MarkdownA cluster replica’s CPU goes to the dataflows that maintain the cluster’s indexes, materialized views, and sinks, and to the ad-hoc queries served from it. An anomaly in CPU usage means one of those started doing more work, or that the same work stopped being spread evenly across the replica’s workers.
Each section below covers one cause, with the queries that diagnose it, how to resolve it, and how to prevent it from recurring.
EXPLAIN ANALYZE statements in this guide
report on the cluster and replica your session is connected to. Run
SET CLUSTER TO <cluster_name> first, and re-run per replica on a cluster with
a replication factor above 1.
Common causes
| Cause | Description |
|---|---|
| Worker skew | One worker does far more work than its peers, so the replica saturates a core while the rest idle. |
| An expensive object | A single dataflow dominates the cluster’s CPU. |
| Hydration | A replica restart, cluster resize, or DDL forced objects to rebuild from their inputs. |
| Ad-hoc query load | SELECTs served by the cluster compete with its maintenance work. |
| Upstream data volume | More data is arriving from sources, so there is more incremental work to do. |
| Memory pressure causing increased spill to disk | A replica near its memory limit burns CPU on I/O rather than on your dataflows. |
| An undersized cluster | The cluster has no headroom left for the workload it carries. |
First, confirm the spike window and scope
Establish when the anomaly started and which replicas it affected, replacing
<cluster_name> with the name of your cluster:
SELECT
u.occurred_at,
r.name AS replica_name,
u.cpu_percent
FROM mz_internal.mz_cluster_replica_utilization_history u
JOIN mz_catalog.mz_cluster_replicas r ON u.replica_id = r.id
JOIN mz_catalog.mz_clusters c ON r.cluster_id = c.id
WHERE c.name = '<cluster_name>'
AND u.occurred_at > now() - INTERVAL '6 hours'
ORDER BY u.occurred_at DESC;
occurred_at | replica_name | cpu_percent
----------------------------+--------------+--------------
2026-09-15 16:26:44.899+00 | r1 | 39.634147625
2026-09-15 16:25:44.9+00 | r1 | 34.412956225
2026-09-15 16:24:44.899+00 | r1 | 34.81781385
2026-09-15 16:23:44.904+00 | r1 | 0
2026-09-15 16:22:46.4+00 | r1 |
(5 rows)
Samples land about once a minute, and each one is a difference against the
previous sample, so the oldest row of a replica’s history has a NULL
cpu_percent.
Every replica of a cluster maintains the same objects, so:
-
If all replicas spike together, the workload itself changed. Continue with the checks below.
-
If one replica spikes, that replica is doing something its peers are not, most often rehydrating after a restart. See Hydration.
-
If the anomaly repeats on a fixed cadence, or CPU stays high while the cluster’s inputs are idle, suspect a temporal filter. A window whose boundary moves with
mz_now()retracts rows as they age out, so it generates work with no upstream input at all.
cpu_percent is a percentage of the replica’s total allocation across all of
its cores, so it averages over workers. A replica whose work is concentrated on
one worker can peg a core while reporting a modest cpu_percent.
Low CPU is therefore not a clean bill of health. Degraded freshness with unremarkable CPU is the signature of worker skew: the busy worker is saturated, and averaging it against idle peers hides that.
Which way the number points tells you which check to run first:
-
CPU is high: the cluster is doing more work than it can absorb. Start with an expensive object.
-
CPU is low but freshness is degraded: the work is not being spread across workers. Start with worker skew.
Worker skew
Materialize distributes work across a replica’s workers by hashing keys, such
as join keys and GROUP BY keys. If one key value accounts for a
disproportionate share of rows, the worker responsible for that value does
disproportionate work while its peers idle.
A cross join is the extreme case. It has no join key to hash on, so every cross join running on a replica moves all of its data to a single worker, no matter how the input values are distributed.
Diagnosing the issue
Step 1. Find the skewed object. Connect to the cluster and run
EXPLAIN ANALYZE CLUSTER CPU WITH SKEW:
SET CLUSTER TO <cluster_name>;
EXPLAIN ANALYZE CLUSTER CPU WITH SKEW;
object | global_id | worker_id | max_operator_cpu_ratio | worker_elapsed | avg_elapsed | total_elapsed
------------------------------------------+-----------+-----------+------------------------+-----------------+-----------------+-----------------
materialize.public.orders_enriched | t356 | 2 | 3.74 | 00:00:00.675664 | 00:00:00.207876 | 00:00:00.831505
materialize.public.orders_enriched | t356 | 1 | 1.85 | 00:00:00.033013 | 00:00:00.207876 | 00:00:00.831505
materialize.public.orders_by_customer | t365 | 1 | 1.57 | 00:00:00.188396 | 00:00:00.120242 | 00:00:00.48097
materialize.public.orders_by_customer | t365 | 2 | 1.17 | 00:00:00.140562 | 00:00:00.120242 | 00:00:00.48097
materialize.public.orders_by_customer | t365 | 3 | 0.81 | 00:00:00.097985 | 00:00:00.120242 | 00:00:00.48097
materialize.public.orders_enriched | t356 | 0 | 0.77 | 00:00:00.079949 | 00:00:00.207876 | 00:00:00.831505
materialize.public.orders_enriched | t356 | 3 | 0.73 | 00:00:00.042878 | 00:00:00.207876 | 00:00:00.831505
materialize.public.orders_by_customer | t365 | 0 | 0.45 | 00:00:00.054026 | 00:00:00.120242 | 00:00:00.48097
(8 rows)
There is one row per worker per dataflow, sorted by ratio, so rows for
different objects interleave. A ratio near 1 means a worker is doing a
roughly average share of the work. Here orders_enriched has a worker at
3.74 on a four-worker replica, close to the theoretical maximum of 4: one
worker is doing nearly all of that dataflow’s work.
max_operator_cpu_ratio is the maximum across all of a dataflow’s operators, so
a high ratio can come from an operator that contributes almost nothing to the
dataflow’s CPU time. Only act on a row whose total_elapsed is also
significant.
Two further caveats:
-
The numbers accumulate from the moment each dataflow was created, so a short burst of skew in a long-running dataflow is diluted by its history. To measure a window, sample twice and compare.
-
On a cluster with many objects the output can run to thousands of rows. Use
EXPLAIN ANALYZE ... AS SQLto get the underlying query, then filter and sort it yourself.
Step 2. Localize the skew to an operator. Drill into the object you identified:
EXPLAIN ANALYZE CPU WITH SKEW FOR MATERIALIZED VIEW orders_enriched;
operator | worker_id | cpu_ratio | worker_elapsed | avg_elapsed | total_elapsed
-----------------------------+-----------+-----------+-----------------+-----------------+-----------------
Differential Join %0 » %1 | 0 | 0.08 | 00:00:00.006928 | 00:00:00.087401 | 00:00:00.349606
Differential Join %0 » %1 | 1 | 0.11 | 00:00:00.009606 | 00:00:00.087401 | 00:00:00.349606
Differential Join %0 » %1 | 2 | 3.73 | 00:00:00.326225 | 00:00:00.087401 | 00:00:00.349606
Differential Join %0 » %1 | 3 | 0.08 | 00:00:00.006846 | 00:00:00.087401 | 00:00:00.349606
Arrange (#0{customer_id}) | 0 | 0.77 | 00:00:00.002539 | 00:00:00.00329 | 00:00:00.01316
Arrange (#0{customer_id}) | 1 | 1.84 | 00:00:00.006057 | 00:00:00.00329 | 00:00:00.01316
Arrange (#0{customer_id}) | 2 | 0.65 | 00:00:00.002136 | 00:00:00.00329 | 00:00:00.01316
Arrange (#0{customer_id}) | 3 | 0.74 | 00:00:00.002426 | 00:00:00.00329 | 00:00:00.01316
Read u11 | | | | |
Arrange (#1{customer_id}) | 0 | 0.6 | 00:00:00.070684 | 00:00:00.117398 | 00:00:00.469594
Arrange (#1{customer_id}) | 1 | 0.15 | 00:00:00.017521 | 00:00:00.117398 | 00:00:00.469594
Arrange (#1{customer_id}) | 2 | 2.96 | 00:00:00.347571 | 00:00:00.117398 | 00:00:00.469594
Arrange (#1{customer_id}) | 3 | 0.29 | 00:00:00.033816 | 00:00:00.117398 | 00:00:00.469594
Read u10 | | | | |
(14 rows)
Use FOR INDEX <object_name> for an index. The operator with the highest
ratio, most often a Join or Reduce, is where the skew originates. Here it
is the join, and the arrangement it reads from names the offending key:
customer_id.
The per-object form reports cpu_ratio rather than the max_operator_cpu_ratio
of the cluster-wide form, since it is already per operator. Leaf Read rows
have no per-worker records of their own and report empty metrics.
Step 3. Find the hot key. A skewed Join or Reduce is almost always
caused by a hot key: one value in the join or GROUP BY column(s) accounts
for far more rows than the rest. Confirm it by counting rows per value:
SELECT customer_id, count(*) AS num_rows
FROM orders
GROUP BY customer_id
ORDER BY num_rows DESC
LIMIT 20;
customer_id | num_rows
-------------+----------
1 | 390000
1024 | 1
1280 | 1
1536 | 1
1792 | 1
2048 | 1
...
(20 rows)
A single value with an outsized count confirms the hot key. A common trigger is
an upstream change that collapses a once-diverse column to a single value or to
NULL. See Is work distributed equally across
workers?
for other causes of skew, such as cross joins and
ORDER BY/LIMIT/OFFSET queries.
count and sum plan as an
accumulable reduce, whose cost barely depends on rows per key, and min/max
over a non-monotonic input plans as a bucketed hierarchical reduce, which
deliberately spreads one key across workers. Joins skew the hardest.
Resolution
Restructure the query so the hot key’s rows aren’t concentrated on a single worker, for example by pre-aggregating or filtering rows before the join, or by adding a column to the key so that values spread across more workers.
If the skew can’t be eliminated, size the cluster for the busiest worker’s load rather than the average, since the other workers won’t absorb it.
Prevention
-
Key joins and aggregations on columns with high cardinality. A column that is mostly one value, or mostly
NULL, concentrates on one worker by construction. -
Watch for upstream schema changes that collapse a once-diverse column to a single value or to
NULL. This is the most common way a healthy dataflow becomes skewed without any change on the Materialize side. -
Test with data whose key distribution matches production. Skew is a property of the data, not of the query, so uniformly distributed test data will not reproduce it.
-
Monitor per-worker CPU rather than the replica average, since
cpu_percenthides skew by construction.
An expensive object
One dataflow can dominate a cluster’s CPU without any skew: the work is spread evenly across workers, there is just a lot of it.
Diagnosing the issue
SET CLUSTER TO <cluster_name>;
EXPLAIN ANALYZE CLUSTER CPU;
object | global_id | total_elapsed
------------------------------------------+-----------+-----------------
materialize.public.orders_enriched | t356 | 00:00:00.832362
materialize.public.orders_by_customer | t365 | 00:00:00.482028
(2 rows)
The output is sorted by total_elapsed, so the objects at the top are the
cluster’s most expensive dataflows. Drill into one with
EXPLAIN ANALYZE CPU FOR MATERIALIZED VIEW <object_name> to see its operators.
total_elapsed accumulates from the moment the dataflow was created, so a
long-lived object can outrank one that is expensive right now. To see which
operators are busy at this moment, use
mz_compute_operator_durations_histogram.
Resolution
Optimize the expensive object. Cross joins and
joins without a suitable index are the usual culprits. Alternatively, move the
object to its own cluster, or size the cluster up with
ALTER CLUSTER ... SET (SIZE = '<new size>').
Prevention
-
Review the plan of a new object with
EXPLAIN PLANbefore it reaches a production cluster, and check that joins use an index on the join key. -
Keep unrelated expensive objects on separate clusters, so that one object’s cost cannot starve the others. See the operational guidelines.
Hydration
Hydration is CPU-intensive: the replica reprocesses each object’s inputs from scratch. Any event that drops a replica’s in-memory state re-runs that work, and hydrating a new object on a busy cluster can saturate it outright.
Diagnosing the issue
Check what is still hydrating, including sources and sinks:
SELECT o.name, h.replica_id
FROM mz_internal.mz_hydration_statuses h
JOIN mz_catalog.mz_objects o ON h.object_id = o.id
JOIN mz_catalog.mz_clusters c ON o.cluster_id = c.id
WHERE c.name = '<cluster_name>'
AND NOT h.hydrated;
name | replica_id
--------+------------
big_mv |
(1 row)
Zero rows is the healthy steady state: everything on the cluster is hydrated.
A NULL replica_id means compute introspection has not reported on the object
yet, which is normal for an object that has only just been created.
Resolution
Hydration resolves itself, so no action is needed unless it keeps recurring. If it does, find what is triggering it:
-
Replica restarts, including OOM crash loops, which rehydrate the whole cluster on every restart.
-
DDL or deploy activity. Creating, altering, or dropping an object hydrates it on whichever cluster it lives on. See Check for DDL or deploy activity.
-
Cluster resizes, which hydrate the new replicas before dropping the old ones.
Prevention
-
Introduce new objects through a blue/green deployment rather than on a live production cluster, so hydration happens on a cluster that is not serving traffic.
-
Size clusters so that they do not OOM, since an OOM crash loop rehydrates the whole cluster on every restart.
-
To shorten hydration without paying for a larger cluster continuously, use autoscaling.
Ad-hoc query load
A cluster serves SELECTs from the same replicas that maintain its indexes and
materialized views, so query traffic and maintenance work compete for the same
CPU. A burst of queries, or a query that can’t be answered from an index, shows
up as a cluster-wide spike. Client-side restart loops are a frequent cause: each
reconnect resubmits the same queries, and each one spins up a temporary
dataflow.
Diagnosing the issue
SET CLUSTER TO <cluster_name>;
SELECT object_id, type, count(*) AS active_peeks
FROM mz_introspection.mz_active_peeks
GROUP BY object_id, type
ORDER BY active_peeks DESC;
object_id | type | active_peeks
-----------+-------+--------------
u9 | index | 2
t331 | index | 1
(2 rows)
A u prefix identifies a user object, here an index answering two reads. A t
prefix is a transient dataflow, built to answer a query that no existing
arrangement could serve. A steady supply of t rows means queries are paying to
build dataflows rather than reading from indexes.
This is a point-in-time snapshot of in-flight reads, so sample it repeatedly
during a spike. For the window after the fact, query
mz_internal.mz_recent_activity_log
instead.
Resolution
Serve queries from a cluster separate from the one maintaining the objects, as described in the operational guidelines.
Prevention
-
Separate serving clusters from compute clusters from the start, so that a burst of query traffic cannot delay maintenance work. Use role-based access control to keep ad-hoc queries off production compute clusters.
-
Index the columns that ad-hoc queries filter on, so that a query is answered from an existing arrangement instead of building a temporary dataflow. A view that is queried repeatedly is cheaper as a materialized view than as an ad-hoc
SELECTover an unmaterialized one. -
Configure clients with backoff on reconnect, so that a client-side restart loop does not multiply into repeated query bursts.
Upstream data volume
A dataflow’s steady-state CPU is proportional to the rate of change flowing through it, not to the size of its inputs. A cluster that was comfortable can saturate when an upstream system starts writing faster, with no change on the Materialize side.
Diagnosing the issue
If the cluster hosts sources, sample their counters twice a minute apart and compare:
SELECT
s.name,
ss.messages_received,
ss.updates_committed
FROM mz_internal.mz_source_statistics ss
JOIN mz_catalog.mz_sources s ON ss.id = s.id
JOIN mz_catalog.mz_clusters c ON s.cluster_id = c.id
WHERE c.name = '<cluster_name>';
name | messages_received | updates_committed
------+-------------------+-------------------
lg | 171 | 170
(1 row)
These counters are best-effort and only meaningful as rates; see counter
metrics. Joining
mz_catalog.mz_sources restricts the result to top-level sources. To include
the tables created from a source, join mz_catalog.mz_objects instead.
Resolution
If the rate is far above what the workload was sized for, either size the cluster up or reduce the volume upstream.
If the sources live on a different cluster from the objects that spiked, check that cluster too: downstream compute inherits its inputs’ change rate. See Check source ingestion.
Prevention
-
Size clusters against the peak change rate rather than the average, and revisit the sizing when upstream workloads change.
-
Monitor freshness so that a rising change rate surfaces as lag before it surfaces as saturation.
Memory pressure causing increased spill to disk
A replica that is close to its memory limit spills data to disk, and the resulting paging registers as CPU time that isn’t doing any of your work.
Diagnosing the issue
Check the other columns of
mz_cluster_replica_utilization
for the same window:
SELECT
r.name AS replica_name,
u.cpu_percent,
u.memory_percent,
u.disk_percent,
u.swap_percent
FROM mz_internal.mz_cluster_replica_utilization u
JOIN mz_catalog.mz_cluster_replicas r ON u.replica_id = r.id
JOIN mz_catalog.mz_clusters c ON r.cluster_id = c.id
WHERE c.name = '<cluster_name>';
replica_name | cpu_percent | memory_percent | disk_percent | swap_percent
--------------+-------------+--------------------+--------------+--------------
r1 | 0.863405875 | 0.8431712962962964 | |
(1 row)
disk_percent and swap_percent are NULL when the replica reports no
corresponding limit, as above. A replica under memory pressure reports rising
values in both.
Resolution
High memory_percent alongside rising disk_percent or swap_percent means
you are looking at a memory problem wearing a CPU costume. Size the cluster up,
or reduce the memory footprint of its objects.
Prevention
-
Alert on
memory_percentas well ascpu_percent, so that memory pressure is caught before it presents as a CPU anomaly. -
Reduce the memory footprint of expensive objects, for example by filtering earlier in the dataflow or by reducing the number of arrangements. See optimization.
An undersized cluster
If CPU is spread evenly across workers with no recent hydration, DDL, or upstream change, the cluster is undersized for its workload.
Diagnosing the issue
To measure how much headroom is left, subscribe to the time workers spend idle.
SUBSCRIBE streams results, so wrap it in COPY ... TO STDOUT or a cursor;
a bare SUBSCRIBE prints nothing in psql.
SET CLUSTER TO <cluster_name>;
BEGIN;
DECLARE c CURSOR FOR SUBSCRIBE (
SELECT sum(slept_for_ns * count)
FROM mz_introspection.mz_scheduling_parks_histogram
);
FETCH 5 c WITH (timeout = '6s');
COMMIT;
mz_timestamp | mz_diff | sum
---------------+---------+---------------
1789489597053 | 1 | 1232890339840
1789489598000 | -1 | 1232890339840
1789489598000 | 1 | 1239430677504
1789489599000 | 1 | 1244927487744
1789489599000 | -1 | 1239430677504
(5 rows)
Each tick emits a retraction (mz_diff = -1) of the previous value and an
insertion (mz_diff = 1) of the new one.
The sum is cumulative parked nanoseconds across all of the replica’s
workers, not a rate, so take the difference between two insertions to get the
idle time for that interval. Over a window of T seconds, a fully idle cluster
accrues T × <number of workers> seconds of idle time. As a rule of thumb, a
cluster with healthy headroom stays above 10% of that.
mz_catalog.mz_cluster_replica_sizes
gives the worker count for a size.
Because this aggregates across workers, it will not reveal skew. Use it
alongside EXPLAIN ANALYZE CLUSTER CPU, not instead of it.
Resolution
Size the cluster up with ALTER CLUSTER ... SET (SIZE = '<new size>') or move objects to another cluster.
Sizing up does not always reduce CPU. A cluster runs each object’s operators on every worker, so the operator count grows with both the number of objects and the number of workers, and coordination overhead grows with it. A cluster that hosts many objects can therefore use more CPU after a resize.
If freshness degrades and CPU rises after sizing up, the cluster is too large for its workload. Size back down, or split the objects across several smaller clusters.
Prevention
-
Track idle time continuously rather than sampling it during an incident, so that shrinking headroom is visible before the cluster saturates.
-
Size from the hydration peak. If CPU during hydration stayed well below the replica’s capacity, the cluster has room to shrink.
-
Prefer several smaller clusters over one large one, particularly when hosting many objects. Cross-cluster joins and strict serializability still hold.
-
Follow the operational guidelines for how to lay out clusters. See Check cluster health for the corresponding freshness symptoms.