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.
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.
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;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.
Durations to scale as measured; start points within the chain are schematic. Each row was executed exactly once in its run.
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 statement | Duration |
|---|---|
Outer UPDATE dbo.Control | 7,687.8 ms |
UPDATE dbo.WorkTarget in the procedure | 7,666.6 ms |
SELECT in the trigger | 0.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.
| Measurement | Result |
|---|---|
| Outer UPDATE, Query Store | 7,369.6 ms |
| SELECT before the TRUNCATE, Query Store | 0.081 ms |
| TRUNCATE, Query Store | no entry of its own |
| SELECT after the TRUNCATE, Query Store | 0.082 ms |
Entire procedure, sys.dm_exec_procedure_stats | 7,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 run | Result |
|---|---|
| Outer UPDATE, Query Store duration | 7,708.5 ms |
| Same plan, wait category “Lock” | 7,693 ms |
| TRUNCATE entry of its own | still 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.
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.
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.
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
| Call | Outer statement | Inner statement | Result |
|---|---|---|---|
| UPDATE → trigger → procedure, inner UPDATE blocked | 7,687.8 ms | 7,666.6 ms | overlaps |
| UPDATE → trigger → procedure, TRUNCATE blocked | 7,369.6 ms | no entry | outer only |
| UPDATE → trigger → procedure, WAITFOR 2 s | 2,002.2 ms | no entry | outer only |
EXEC Level1 → Level2 → Level3, UPDATE blocked | no entry | 4,302.9 ms | inner only |
INSERT … EXEC Level1, UPDATE blocked | 4,261.9 ms | 4,249.7 ms | overlaps |
EXEC chain, WAITFOR 2 s in Level3 | no entry | no entry | missing |
| Scalar UDF, not inlined | 4,496.6 ms | 4,495.9 ms | overlaps |
| Scalar UDF, actually inlined | 4,729.4 ms | no entry | outer only |
| Inline TVF | 4,457.2 ms | no entry | outer only |
| Multi-statement TVF | 4,476.2 ms | 4,474.4 ms | overlaps |
“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.
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 byobject_idcontains 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 insys.dm_exec_requestsduring 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.