Last Used Stored Procedure in SQL Server: When Did It Last Run?

The last used stored procedure is the one with the newest last_execution_time in the plan cache. SQL Server keeps no “last used” date on the procedure itself. The date lives in a view that forgets, so read it with care.

Gouache painting of a pegboard with grey tool shapes and a screwdriver with a vermilion handle lying on the bench

Where the Date Lives

Every procedure that runs gets a row in sys.dm_exec_procedure_stats while its plan stays in the cache. The row holds the run count and the time of the last run. A LEFT JOIN from sys.procedures keeps the procedures that have no row, and those show NULL.

Older scripts read sys.dm_exec_query_stats instead. That view counts statements, not procedures, so a procedure with ten statements shows up ten times. The procedure view returns one row per procedure.

The demo database has a shelf table and four procedures. The first block builds them and turns on Query Store, which a later section explains. Query Store needs a moment to start capturing, so the block waits three seconds after switching it on. It runs ListShelf three times, CloseMonth once and FreshEveryTime twice. OldYearReport never runs.

IF DB_ID(N'LastRunProcDemo') IS NULL CREATE DATABASE LastRunProcDemo;
GO
ALTER DATABASE LastRunProcDemo SET QUERY_STORE = ON (QUERY_CAPTURE_MODE = ALL, INTERVAL_LENGTH_MINUTES = 1);
WAITFOR DELAY '00:00:03';
GO
USE LastRunProcDemo;
GO
DROP TABLE IF EXISTS dbo.Shelf;
CREATE TABLE dbo.Shelf (BookID int NOT NULL PRIMARY KEY, Title nvarchar(60) NOT NULL);
INSERT INTO dbo.Shelf VALUES (1, N'Herb Garden Basics'), (2, N'Tea Around the World');
GO
CREATE OR ALTER PROCEDURE dbo.ListShelf AS SELECT BookID, Title FROM dbo.Shelf;
GO
CREATE OR ALTER PROCEDURE dbo.CloseMonth AS UPDATE dbo.Shelf SET Title = Title WHERE BookID = 1;
GO
CREATE OR ALTER PROCEDURE dbo.OldYearReport AS SELECT COUNT(*) AS Books FROM dbo.Shelf;
GO
CREATE OR ALTER PROCEDURE dbo.FreshEveryTime WITH RECOMPILE AS SELECT TOP (1) Title FROM dbo.Shelf ORDER BY BookID;
GO
EXEC dbo.ListShelf;
EXEC dbo.ListShelf;
EXEC dbo.ListShelf;
EXEC dbo.CloseMonth;
EXEC dbo.FreshEveryTime;
EXEC dbo.FreshEveryTime;
SELECT p.name AS ProcedureName, ps.execution_count AS Runs, ps.last_execution_time AS LastRun
FROM sys.procedures AS p
LEFT JOIN sys.dm_exec_procedure_stats AS ps ON ps.object_id = p.object_id AND ps.database_id = DB_ID()
ORDER BY p.name;
ProcedureNameRunsLastRun
CloseMonth12026-10-07 11:30:18.937
FreshEveryTimeNULLNULL
ListShelf32026-10-07 11:30:18.933
OldYearReportNULLNULL

The times are server local time and change with every run. ListShelf shows three runs, CloseMonth one, and OldYearReport has no row, as expected. FreshEveryTime ran twice and still has no row. A procedure created WITH RECOMPILE never keeps a plan in the cache, so this view cannot see it. The view needs VIEW SERVER STATE, or VIEW SERVER PERFORMANCE STATE on SQL Server 2022 and later.

NULL Does Not Mean Never

A plan also leaves the cache after a restart, under memory pressure, or when someone clears it. The next statement clears the plans of this database only, and nothing else on the server. It needs SQL Server 2019 or later. Then the same query runs again.

ALTER DATABASE SCOPED CONFIGURATION CLEAR PROCEDURE_CACHE;
SELECT p.name AS ProcedureName, ps.execution_count AS Runs, ps.last_execution_time AS LastRun
FROM sys.procedures AS p
LEFT JOIN sys.dm_exec_procedure_stats AS ps ON ps.object_id = p.object_id AND ps.database_id = DB_ID()
ORDER BY p.name;
ProcedureNameRunsLastRun
CloseMonthNULLNULL
FreshEveryTimeNULLNULL
ListShelfNULLNULL
OldYearReportNULLNULL

All four procedures look unused now, and three of them ran seconds ago. A NULL says only that no plan is in the cache. Dropping a procedure on that evidence is how a month-end job breaks a month later. For the counting side of the same view, read Frequently Run Stored Procedures: Find Them in the Cache.

A Longer Memory With Query Store

Query Store keeps runtime numbers inside the database, so a restart or a cache clear does not erase them. It also records procedures created with recompile. The first block turned it on with QUERY_CAPTURE_MODE = ALL, which records cheap statements too. The default mode skips queries it judges insignificant, and these are tiny.

Quick card titled Last Used Procedure Checklist: Cache: Rows live in sys.dm_exec_procedure_stats. NULL: Not in cache, not proof of never. Recompile: WITH RECOMPILE procedures never appear. Query Store: Keeps runs in the database. Time: Query Store times are UTC. Tip: Watch a full business cycle before dropping.

The next script waits two seconds and then flushes its memory to disk. The query joins each procedure to its queries, plans and run statistics.

WAITFOR DELAY '00:00:02';
EXEC sys.sp_query_store_flush_db;
GO
SELECT p.name AS ProcedureName, MAX(rs.last_execution_time) AS LastRun, SUM(rs.count_executions) AS Runs
FROM sys.procedures AS p
LEFT JOIN sys.query_store_query AS q ON q.object_id = p.object_id
LEFT JOIN sys.query_store_plan AS pl ON pl.query_id = q.query_id
LEFT JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = pl.plan_id
GROUP BY p.name
ORDER BY p.name;

A complete result lists CloseMonth, FreshEveryTime and ListShelf with a run count and a time, and OldYearReport stays NULL. This is one possible result, and the times vary.

ProcedureNameLastRunRuns
CloseMonth2026-10-07 07:06:09.5400000 +00:001
FreshEveryTime2026-10-07 07:06:09.5470000 +00:002
ListShelf2026-10-07 07:06:09.5300000 +00:003
OldYearReportNULLNULL

The times are UTC, with a +00:00 offset. They differ from the cache view by the server time zone. The cache was cleared before this query, and Query Store still answered.

The counts need a warning. Query Store captures runs in a background task. A run or a whole procedure can be missing from the result. The counts can differ from one run to the next. Treat Query Store as a longer memory, not an audit log. Let it run a while before you trust a NULL.

How Long to Watch Before You Drop

A last used stored procedure date needs a long window. A procedure that runs once a year needs a year of watching. The cache cannot give you that, and Query Store can only start counting from the day you enable it. Watch through every business cycle, such as month end and year end, before you call a procedure unused.

You could argue that a log table is more certain. Each procedure writes a row when it starts, and nothing is ever forgotten. That is true. It also means editing every procedure and paying for a write on every call. Query Store gives most of that value without changing any code.

When you do find a candidate, do not drop it first. Rename it, or deny execute permission, and wait for complaints. Both steps are easy to undo, and a drop is not.

What to Remember

The last used stored procedure comes from last_execution_time in sys.dm_exec_procedure_stats. A NULL means no plan is cached, not that the procedure never ran. Use Query Store for a memory that survives restarts, and watch a full business cycle.

Clean up the demo when you finish.

USE master;
GO
ALTER DATABASE LastRunProcDemo SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE LastRunProcDemo;

A missing date is not proof of an unused procedure, it is a cache that forgot.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

Query Store, SQL Scripts, SQL Stored Procedure
Previous Post
Checking Last Good DBCC CHECKDB Time With LastGoodCheckDbTime
Next Post
Flush Authentication Cache: Who Can Run DBCC FLUSHAUTHCACHE

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.