From the lab · Query Store

Where does the time go?

An UPDATE takes 7.4 seconds. The procedure its trigger calls comes to 0.16 milliseconds in Query Store. Both numbers are correct. We measured how Query Store captures duration for triggers, procedures and functions, and where a total or a ranking points you in the wrong direction.

30 September 2026 · Sascha Lorenz · approx. 10 minutes

Measured SQL Server 2019Cases 14Metric DurationPlay along 7 questions ↓

The setup

A small control table has an AFTER UPDATE trigger. The trigger calls a procedure synchronously. The outer UPDATE does not finish until that work is done as well:

UPDATE→Trigger→Procedure→inner statement

A second session blocks the inner statement for a few seconds. Afterwards, we read what Query Store recorded for this one execution and compare it with the server’s module statistics.

The trigger path in the TRUNCATE experiment (abridged)
CREATE TRIGGER dbo.ControlUpdate ON dbo.Control AFTER UPDATE
AS
BEGIN
    SET NOCOUNT ON;
    EXEC dbo.WorkTruncate;
END;

CREATE PROCEDURE dbo.WorkTruncate
AS
BEGIN
    SET NOCOUNT ON;
    DECLARE @before int, @after int;
    SELECT @before = Value FROM dbo.WorkTarget WHERE Id = 1;
    TRUNCATE TABLE dbo.TruncateTarget;   -- waits for a Sch-M lock
    SELECT @after  = Value FROM dbo.WorkTarget WHERE Id = 1;
END;
Interactive · The call chain in fast motion

Every captured statement has its own clock.

Query Store measures duration per statement, from start to finish. If more captured work runs inside a statement, two clocks run at the same time. Work without a captured statement of its own has no clock at all.

Call and levelTimeQuery Store
0.0 s

Durations to scale as measured; start points within the chain are schematic. Each row was executed exactly once in its run.

What the fast-motion view shows

Two limits that are easy to miss

1. Measurement scopes overlap

In the first experiment, the procedure contains an UPDATE on another table, which a second session blocks. Afterwards, Query Store shows:

Captured statementDuration
Outer UPDATE dbo.Control7,687.8 ms
UPDATE dbo.WorkTarget in the procedure7,666.6 ms
SELECT in the trigger0.083 ms

Both large values match the execution. The outer duration includes the synchronously called work, and the inner UPDATE has its own duration on top. Adding them up gives 15.35 seconds for a call that took 7.69 seconds. This is not a second execution; it is the same wait in two measurement scopes. Deduplicating by query or plan ID does not change that.

2. Work without an entry of its own

In the second experiment, we replace the inner UPDATE with TRUNCATE TABLE. The second session holds a table lock; the TRUNCATE waits for a Sch-M lock (observed live: LCK_M_SCH_M). The lock it needs is described in Microsoft’s TRUNCATE documentation.

MeasurementResult
Outer UPDATE, Query Store7,369.6 ms
SELECT before the TRUNCATE, Query Store0.081 ms
TRUNCATE, Query Storeno entry of its own
SELECT after the TRUNCATE, Query Store0.082 ms
Entire procedure, sys.dm_exec_procedure_stats7,365.2 ms

The procedure’s visible statements add up to 0.163 milliseconds. The procedure itself took more than seven seconds. Query Store captures plans and runtime statistics for DML statements; plans for DDL are generally not captured, as described in Microsoft’s Query Store overview. That the blocked TRUNCATE therefore has no row of its own, and that its wait shows up in the outer UPDATE instead, is what we measured.

Is the lock wait missing too?

No. We repeated the same chain in a new run with wait statistics capture enabled:

Measurement in the new runResult
Outer UPDATE, Query Store duration7,708.5 ms
Same plan, wait category “Lock”7,693 ms
TRUNCATE entry of its ownstill none

The lock wait does reach Query Store, attributed to the outer UPDATE. The inner SELECTs had no wait rows of their own. Query Store records only the category, not the specific wait type and not the blocker (sys.query_store_wait_stats). We know the session was waiting on LCK_M_SCH_M at the TRUNCATE from live observation, not from the Query Store row.

Interactive · Same numbers, three reports

One call, three answers.

The raw data in Query Store is correct. What a report makes of it depends on how it aggregates. Choose an experiment and a typical view.

Cross-checks

Is every kind of nesting affected?

No, and that is exactly why a closer look pays off. We tested the question with further experiments without triggers: three ordinary procedures calling one another with EXEC, the same chain under INSERT … EXEC, a scalar function with and without inlining, and an inline and a multi-statement table-valued function. In each case, a second session holds the row being read or changed for a few seconds.

Before you see the results: play along.

Interactive · Where does the wait end up?

The rule of thumb from our measurements

Time appears twice when a captured statement encloses the inner work and the inner work has captured statements of its own: DML on a table with a trigger, INSERT … EXEC, non-inlined scalar UDFs, multi-statement TVFs. A gap appears when work has no captured statement of its own, such as DDL like TRUNCATE, or WAITFOR. If no captured statement encloses that work, as in a plain EXEC chain, it is missing from Query Store duration altogether.

All cases at a glance
CallOuter statementInner statementResult
UPDATE → trigger → procedure, inner UPDATE blocked7,687.8 ms7,666.6 msoverlaps
UPDATE → trigger → procedure, TRUNCATE blocked7,369.6 msno entryouter only
UPDATE → trigger → procedure, WAITFOR 2 s2,002.2 msno entryouter only
EXEC Level1 → Level2 → Level3, UPDATE blockedno entry4,302.9 msinner only
INSERT … EXEC Level1, UPDATE blocked4,261.9 ms4,249.7 msoverlaps
EXEC chain, WAITFOR 2 s in Level3no entryno entrymissing
Scalar UDF, not inlined4,496.6 ms4,495.9 msoverlaps
Scalar UDF, actually inlined4,729.4 msno entryouter only
Inline TVF4,457.2 msno entryouter only
Multi-statement TVF4,476.2 ms4,474.4 msoverlaps

“Inner statement” means the blocked or waiting statement. For the multi-statement TVF, we also measured with interleaved execution disabled: 4,752.8 ms outer, 4,751.6 ms inner. A scalar wrapper function that calls the reading function did not get a Query Store row of its own; there, again, only the outer SELECT and the inner read overlapped (4,780.2 ms and 4,779.4 ms).

For functions, the actual plan shape decides, not the function name. Whether a scalar UDF was inlined, we checked in the plan: no UDF operator, and the table access directly in the outer plan. The metadata is_inlineable only says that inlining would be possible (Scalar UDF Inlining). Incidentally, the optional plan attribute for inlined UDFs was missing from the stored Query Store plans of this build even in the inlined case. Its absence is therefore no proof to the contrary.

For your analysis

What we take away from this

For us, Query Store remains the most important source for a database’s runtime history. The measurements sharpen which questions it can answer. Before we derive a tuning measure from a ranking, we clarify:

  • Which part of the execution does this value cover?
    The duration of a DML statement includes its triggers. A slow UPDATE therefore does not prove that its visible plan is the cause.
  • Does a captured statement enclose further captured work?
    Then the rows are not additive. Shares of a grand total are, in that case, shares of a total with overlaps, not exclusive shares.
  • Which work has no row of its own?
    DDL, WAITFOR, the EXEC calls themselves. Fast statements in a procedure do not prove a fast procedure. A value grouped by object_id contains only the module’s own captured statements.
  • What do the module statistics say?
    sys.dm_exec_procedure_stats, sys.dm_exec_trigger_stats and sys.dm_exec_function_stats provide inclusive elapsed times. They depend on the plan cache and are not a history; TVFs and inlined scalar UDFs have no row there.
  • Who actually waited, and on whom?
    Query Store reports the wait category on the outer statement. The statement that was actually waiting and the blocking chain are visible in sys.dm_exec_requests during the incident, or in a targeted Extended Events capture.

Limits of these measurements

All experiments ran on SQL Server 2019 Developer, build 15.0.4490.9 (CU32 GDR), compatibility level 150, with Query Store READ_WRITE, capture mode ALL and wait statistics capture enabled. Each case ran in its own container with synthetic tables; each statement listed was executed once. These are measurements from controlled experiments, not production benchmarks. On 1 October 2026, we repeated all cases in fresh containers from the same image. Every pattern shown here occurred again; the blocking durations differed by up to about eleven percent from the values shown, because they depend on how far apart the sessions start.

We examined duration only. Whether CPU, reads or tempdb usage overlap in the same way does not follow from this. We evaluated wait attribution only for the TRUNCATE chain. Not examined: other server versions, CLR and natively compiled functions, repeated calls across many rows, dynamic SQL, recursion, and a multi-statement TVF that demonstrably ran with interleaved execution.

Further reading

Kendra Little shows in detail how to trace triggers with Query Store and Extended Events. Erin Stellato explains how to analyze the individual statements of a procedure in Query Store. Our measurements add the cases shown here to these articles.