The wall clock is four phases, not one number
Databricks' query profile visualizes a query's execution as a directed graph of operators, but the number people actually care about first is the wall-clock duration -- and that duration is itself a sum of phases that have nothing to do with the graph's shape. In system.query.history, the relevant columns are waiting_at_capacity_duration_ms (queued because the warehouse was already at its concurrency limit), waiting_for_compute_duration_ms (waiting for compute to actually provision), compilation_duration_ms (loading metadata and optimizing the statement), and execution_duration_ms (the phase the operator graph actually describes). total_duration_ms is documented as the statement's total execution time excluding result fetch time -- which has its own column, result_fetch_duration_ms, tracking how long it took to stream the results back to the client after execution finished.
See it side by side
Same operator graph, four different bottlenecks
Click a scenario. Watch which segment grows -- and which numbers barely move at all.
Cause 1: waiting at capacity
A warehouse or cluster can only run a limited number of queries at once. Once that concurrency limit is hit, new queries wait in line before any planning or execution starts at all -- recorded as waiting_at_capacity_duration_ms, and it adds straight to total_duration_ms while execution_duration_ms stays at zero, because the query hasn't started yet. This is the most common reason a query feels slow during a busy hour and fast at 2am on the exact same warehouse.
Fix: more concurrency headroom -- a larger warehouse cluster count, or moving a noisy scheduled workload off the warehouse that interactive users depend on -- removes the contention. It does nothing to speed up any individual query once it actually starts running.
Cause 2: waiting for compute
waiting_for_compute_duration_ms is time spent waiting for compute resources to actually provision -- a stopped SQL warehouse cold-starting, or a cluster scaling up to meet demand. The first query after a stop or auto-suspend pays this cost before compilation even begins, and execution on that same first query often runs a little slower on top of it, because the disk cache on the new compute was empty and the first pass has to re-read source files from remote storage instead of from local disk.
Fix: this is a real trade-off, not a bug -- aggressive auto-stop saves compute cost during idle periods but adds this latency to the first query of every new session. A longer auto-stop window, or keeping a small warehouse always on for latency-sensitive dashboards, trades some idle-time cost for consistently warm starts.
Cause 3: compilation, before a file opens
compilation_duration_ms is documented as time spent loading metadata and optimizing the statement -- entirely before a single data file is read. A query built from many joins, a long chain of nested views, or several layers of CTEs can spend real, measurable time here regardless of how little data the finished plan will eventually touch. Like Cause 1, this phase has nothing to do with the state of the warehouse -- it shows up identically whether compute is warm, cold, idle, or busy.
Cause 4: execution dominated by shuffle
Once a query reaches execution_duration_ms, the operator graph's own cost structure takes over. A shuffle operator moves data between executors across the network to satisfy a join or an aggregation, and the query profile's own documentation describes shuffle as expensive for exactly that reason. A join that explodes the row count, or one where neither side can be broadcast because both are large, forces a full shuffle of both sides -- and shuffle_read_bytes in system.query.history will show it plainly, even when produced_rows at the end is small.
Fix: checking the join keys and row counts on both sides of a suspiciously slow join, and confirming the smaller side is actually eligible to be broadcast instead of shuffled, is the first thing to check before assuming the cluster just needs to be bigger.
Key takeaways
- total_duration_ms = waiting_at_capacity + waiting_for_compute + compilation + execution -- and it explicitly excludes result_fetch_duration_ms.
- Waiting at capacity is a concurrency problem; waiting for compute is a cold-start problem. The fix for one does nothing for the other.
- Compilation time scales with query complexity (joins, nested views, CTEs), not with how much data the query will touch.
- A tidy operator graph and a small result set don't rule out an expensive shuffle -- check shuffle_read_bytes before assuming the cluster needs to be bigger.
Reading the DAG: operators and pruning
Below the phase breakdown, the profile's main view is a graph of operators -- Scan, Join, Union, Shuffle, Hash/Sort, and Filter are the common ones. Three details are worth knowing before reading it:
Photon grouping
Some non-Photon operations are executed as a group and share metrics with their parent operator in the graph, so a slow step can be hiding inside a neighboring box instead of showing as its own node.
Data pruned %
Shown as a percentage next to filter icons during scanning. A low percentage despite a selective-looking filter means the table's file layout didn't line up with that column -- more in the FAQ below.
Top operators + insights
The profile separately ranks the longest-running operators and lists performance insights ordered by their projected effect on total task duration -- a faster way in than reading the whole graph node by node.
Diagnostic checklist
Next time a query feels slower than its operator graph suggests, pull it from system.query.history and check, in order: is waiting_at_capacity_duration_ms or waiting_for_compute_duration_ms non-trivial? Is compilation_duration_ms unusually high for how simple the query looks? Is shuffle_read_bytes large relative to produced_rows? The downloadable script below runs all three checks against your own warehouse.