DBA Scripts: Get Job Schedules and Duration Trends

🔧Part of the DBA-Tools Project, copy/paste SQL Server scripts and health checks.In: Maintenance & Automation › SQL Agent & Jobs

When Jobs Run, and Whether They’re Taking Longer to Do It

SQL Agent Job Failure Summary tells you what broke. These two scripts answer two quieter but just as important questions: Get-JobScheduleSummary shows exactly when every enabled job is scheduled to run next, useful for spotting overlapping windows or confirming a schedule change actually took effect. Get-JobDurationTrends compares each job’s most recent run against its own 30-day average, flagging jobs that are quietly taking longer than they used to, before that becomes a missed maintenance window or an overlap with something else.


Why Job Schedules and Duration Trends Matter

  • Two maintenance jobs scheduled into the same window can silently compete for I/O and CPU, neither one fails, but both run slower than expected, and nothing in a failure report catches it
  • A job that succeeds every time can still be quietly getting slower, run duration trending upward is an early signal worth catching before it turns into a real problem (an overrun into business hours, a missed backup window)
  • next_run_date/next_run_time is the fastest way to confirm a schedule change actually took effect, rather than assuming it did
  • trend_status = SPIKE or GROWING gives you an objective, threshold-based signal instead of having to eyeball raw duration numbers across dozens of jobs

When to Run These Scripts

  • Get-JobScheduleSummary, after any schedule change, to confirm the new next-run time is correct; when reviewing an unfamiliar server, to see what’s actually running and when
  • Get-JobDurationTrends, as a routine weekly or monthly check, alongside Job Failure Summary, to catch degrading jobs before they fail outright
  • When investigating a maintenance window overrun or unexpected resource contention during a specific time slot
  • Before adding a new scheduled job, to check what’s already running in the target window

The Scripts

1. Get-JobScheduleSummary — Enabled Jobs, Schedules, and Next Run Time

✓ Verified

  • Tested on: SQL Server 2025 (RTM CU8), Windows lab instance
  • Last verified: 2026-08-30 (all 2 scripts on this page run, saved outputs from real runs)
  • Permissions: db_datareader on msdb · db_datareader on msdb (or sysadmin); SQLAgentReaderRole alone cannot SELECT the job tables
  • Safety: read-only, impact low

/*
Script Name : Get-JobScheduleSummary
Category    : configuration-and-environment
Purpose     : Show enabled SQL Agent jobs with their schedules and next scheduled run time.
Author      : Peter Whyte (https://sqldba.blog/dba-scripts-get-job-schedules-and-duration-trends/)
Requires    : db_datareader on msdb
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;

SELECT
    j.name AS job_name,
    sc.name AS schedule_name,
    CASE sc.freq_type
        WHEN 1 THEN 'Once'
        WHEN 4 THEN 'Daily'
        WHEN 8 THEN 'Weekly'
        WHEN 16 THEN 'Monthly'
        WHEN 32 THEN 'Monthly (relative)'
        WHEN 64 THEN 'On Agent start'
        WHEN 128 THEN 'When CPU idle'
        ELSE CAST(sc.freq_type AS VARCHAR(10))
    END AS freq_type,
    sc.freq_interval,
    STUFF(STUFF(RIGHT('000000' + CAST(sc.active_start_time AS VARCHAR(6)), 6), 5, 0, ':'), 3, 0, ':') AS scheduled_start_time,

    /* msdb stores these as yyyymmdd + HHmmss integers; return real datetimes so the output
       reads without decoding (matches Get-JobDurationTrends on the same page) */
    CASE WHEN jsch.next_run_date = 0 THEN NULL
         ELSE CONVERT(DATETIME,
                  STUFF(STUFF(CAST(jsch.next_run_date AS CHAR(8)), 7, 0, '-'), 5, 0, '-') + ' ' +
                  STUFF(STUFF(RIGHT('000000' + CAST(jsch.next_run_time AS VARCHAR(6)), 6), 5, 0, ':'), 3, 0, ':'))
    END AS next_run_at,

    CASE jss.last_run_outcome
        WHEN 0 THEN 'Failed'
        WHEN 1 THEN 'Succeeded'
        WHEN 3 THEN 'Cancelled'
        ELSE 'Unknown'
    END AS last_outcome,

    CASE WHEN jss.last_run_date IS NULL OR jss.last_run_date = 0 THEN NULL
         ELSE CONVERT(DATETIME,
                  STUFF(STUFF(CAST(jss.last_run_date AS CHAR(8)), 7, 0, '-'), 5, 0, '-') + ' ' +
                  STUFF(STUFF(RIGHT('000000' + CAST(jss.last_run_time AS VARCHAR(6)), 6), 5, 0, ':'), 3, 0, ':'))
    END AS last_run_at,

    /* packed HHMMSS integer -> HH:MM:SS */
    CASE WHEN jss.last_run_date IS NULL OR jss.last_run_date = 0 THEN NULL
         ELSE RIGHT('0' + CAST(jss.last_run_duration / 10000 AS varchar(4)), 2) + ':'
            + RIGHT('0' + CAST((jss.last_run_duration % 10000) / 100 AS varchar(2)), 2) + ':'
            + RIGHT('0' + CAST(jss.last_run_duration % 100 AS varchar(2)), 2)
    END AS last_run_duration
FROM msdb.dbo.sysjobs AS j
JOIN msdb.dbo.sysjobschedules AS jsch ON j.job_id = jsch.job_id
JOIN msdb.dbo.sysschedules AS sc ON jsch.schedule_id = sc.schedule_id
LEFT JOIN msdb.dbo.sysjobservers AS jss ON j.job_id = jss.job_id
WHERE j.enabled = 1
  AND sc.enabled = 1
ORDER BY jsch.next_run_date, jsch.next_run_time;

2. Get-JobDurationTrends — Duration vs 30-Day Average

/*
Script Name : Get-JobDurationTrends
Category    : monitoring
Purpose     : SQL Agent job duration over the last 30 days — flags jobs that are running significantly longer than their average.
Author      : Peter Whyte (https://sqldba.blog/dba-scripts-get-job-schedules-and-duration-trends/)
Requires    : db_datareader on msdb (or sysadmin); SQLAgentReaderRole alone cannot SELECT the job tables
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;

WITH history AS (
    SELECT
        j.job_id,
        j.name AS job_name,
        j.enabled,
        j.category_id,
        /* Convert HHMMSS integer to total seconds */
        (h.run_duration / 10000) * 3600
        + ((h.run_duration / 100) % 100) * 60
        + (h.run_duration % 100) AS duration_sec,
        /* sysjobhistory.run_date/run_time is when the run STARTED, not when it finished */
        CONVERT(DATETIME,
            STUFF(STUFF(CAST(h.run_date AS CHAR(8)),7,0,'-'),5,0,'-') + ' ' +
            STUFF(STUFF(RIGHT('000000' + CAST(h.run_time AS VARCHAR(6)),6),5,0,':'),3,0,':')
        ) AS run_started_at,
        ROW_NUMBER() OVER (
            PARTITION BY j.job_id
            ORDER BY h.run_date DESC, h.run_time DESC
        ) AS rn
    FROM msdb.dbo.sysjobhistory h
    JOIN msdb.dbo.sysjobs j ON j.job_id = h.job_id
    WHERE h.step_id = 0
      AND h.run_status = 1 /* successful completions only */
      AND h.run_date >= CAST(CONVERT(CHAR(8), DATEADD(DAY, -30, GETDATE()), 112) AS INT)
),
agg AS (
    SELECT
        job_id, job_name, enabled,
        COUNT(*) AS runs_last_30d,
        MAX(CASE WHEN rn = 1 THEN duration_sec END) AS last_run_sec,
        MAX(CASE WHEN rn = 1 THEN run_started_at END) AS last_run_started_at,
        CAST(AVG(CAST(duration_sec AS FLOAT)) AS DECIMAL(10,1)) AS avg_sec_30d,
        MAX(duration_sec) AS max_sec_30d,
        MIN(duration_sec) AS min_sec_30d
    FROM history
    GROUP BY job_id, job_name, enabled
    HAVING COUNT(*) >= 2
)
SELECT
    job_name,
    enabled,
    runs_last_30d,
    CONVERT(VARCHAR(8), DATEADD(SECOND, last_run_sec, 0), 108) AS last_run_duration,
    CONVERT(VARCHAR(8), DATEADD(SECOND, CAST(avg_sec_30d AS INT), 0), 108) AS avg_duration_30d,
    CONVERT(VARCHAR(8), DATEADD(SECOND, max_sec_30d, 0), 108) AS max_duration_30d,
    CAST(
        CASE WHEN avg_sec_30d > 0
             THEN (CAST(last_run_sec AS FLOAT) - avg_sec_30d) / avg_sec_30d * 100
             ELSE 0
        END AS DECIMAL(6,1)) AS pct_vs_avg,
    CASE
        WHEN avg_sec_30d > 0
             AND last_run_sec > avg_sec_30d * 2
             AND (last_run_sec - avg_sec_30d) > 30 THEN 'SPIKE'
        WHEN avg_sec_30d > 0
             AND last_run_sec > avg_sec_30d * 1.25
             AND (last_run_sec - avg_sec_30d) > 10 THEN 'GROWING'
        ELSE 'OK'
    END AS trend_status,
    /* Raw seconds for charting */
    last_run_sec,
    CAST(avg_sec_30d AS INT) AS avg_sec_30d,
    max_sec_30d,
    /* was CONVERT(VARCHAR(16), ..., 120): style 120 is 19 chars, so VARCHAR(16) silently
       truncated the seconds. Return the real datetime, matching Get-JobScheduleSummary
       on the same post. */
    last_run_started_at
FROM agg
ORDER BY pct_vs_avg DESC, job_name;

How To Run From The Repo

Clone DBA Tools, initialize and run whichever script answers your question:

# Clone dba-tools repo:
git clone https://github.com/peterwhyte-lgtm/dba-tools

# Initialize environment:
cd dba-tools
.\Initialize-Environment.ps1

# Enabled jobs, schedules, and next run time:
.\run.ps1 Get-JobScheduleSummary

# 30-day job duration trend, flags SPIKE/GROWING jobs:
.\run.ps1 Get-JobDurationTrends

# Either against a remote sql server:
.\run.ps1 Get-JobDurationTrends -ServerInstance SQLSERVER01

These scripts live in the repo at:


Example Output

1. The schedules. Nineteen enabled jobs on this instance, every schedule reading in plain datetimes rather than the packed integers msdb stores. The row worth noticing is the one whose last_outcome says Failed: the schedule is healthy and firing, and the job itself is not, which is precisely the distinction this pair of scripts exists to keep separate.

SSMS results grid from the Get-JobScheduleSummary script listing nineteen enabled SQL Agent jobs with their schedule name, frequency, scheduled start time, next run time and last run outcome, with one job showing a Failed outcome

2. The trends. The same jobs judged against their own 30-day history. Most sit at OK, several comfortably faster than average, and one is flagged GROWING at 55.8% above its baseline. That job has not failed and would not appear in any failure report; it is simply taking longer than it used to, which is the quiet signal that usually arrives before the loud one.

SSMS results grid from the Get-JobDurationTrends script comparing each job’s last run duration against its 30 day average, with percentage difference and a trend status column, where one job is flagged GROWING at 55.8 percent above its average

Understanding the Results

job_name
schedule_name
Which job, and which of its schedules. A job can carry several, so the same job name appearing more than once is normal, not duplication.
freq_type
freq_interval
scheduled_start_time
The schedule as configured: how often it recurs, its interval, and the time of day it is set to start.
next_run_at
When this schedule fires next, as a real datetime. Blank means there is no future occurrence, which is normal for a one-time schedule that has already run. Act when it is blank on a job you expect to keep running. The schedule is no longer going to fire, whatever the job’s enabled flag says.
last_outcome
last_run_at
last_run_duration
How the last run went, when it began, and how long it took, in HH:MM:SS. All three are blank for a job that has never run. Note these are start times, not completion times, which is why the trends script names its equivalent column last_run_started_at: on a job that ran for hours, reading it as a finish time mis-places the incident by the length of the run.
runs_last_30d
How many successful runs the trend is calculated from. Jobs with fewer than two are excluded entirely: there is no meaningful average to compare against yet, so their absence here is deliberate rather than a gap.
avg_duration_30d
max_duration_30d
min_duration_30d
The 30-day baseline the last run is judged against. A wide gap between min and max is itself informative: a job whose runtime already swings hard is one whose “normal” is harder to trust.
trend_status
OK means the last run sits within about 25% of the average. GROWING means at least 25% longer, and more than ten seconds slower in absolute terms. Act when SPIKE appears: the last run took at least double its 30-day average, and more than thirty seconds longer. Check blocking, resource contention, or a data volume change that legitimately explains it, the same day rather than at the weekly review.


Best Practices

  • Run Get-JobScheduleSummary after any schedule change to confirm the new next-run time is what you expect, don’t assume a change in SSMS took effect correctly
  • Treat trend_status = SPIKE as worth same-day investigation, not filed away for a weekly review
  • Check Get-JobScheduleSummary‘s scheduled_start_time column across all jobs before adding a new one, to avoid stacking maintenance work into an already-busy window
  • Pair with Job Failure Summary for the full picture, failures are the loud signal, duration trends are the quiet one that precedes them

Microsoft’s reference covers dbo.sysschedules, dbo.sysjobschedules, and dbo.sysjobhistory in full.


Related Scripts

You may also find these scripts useful:


Frequently Asked Questions

Why does last_run_duration in Get-JobScheduleSummary differ from Get-JobDurationTrends?

Get-JobScheduleSummary pulls last_run_duration straight from msdb.dbo.sysjobservers, an HHMMSS integer covering the whole job regardless of outcome. Get-JobDurationTrends only counts successful completions (run_status = 1) at the job level (step_id = 0) over a rolling 30-day window, so a recent failed run or a run older than 30 days won’t appear in the trend calculation even though it’s reflected in the schedule summary.

What counts as “significantly longer” for the SPIKE threshold?

At least double the 30-day average AND more than 30 seconds of absolute difference, both conditions have to be true. This avoids flagging a job that normally takes 2 seconds and one day takes 5, technically “more than double” but not actually meaningful.


Summary

A job that succeeds every time can still be quietly heading toward trouble, either through schedule overlap that nothing reports as a failure, or through duration creep that only becomes visible once you compare against its own history. These two scripts turn both into a direct, readable check: what’s scheduled and when, and whether today’s run matches the last 30 days or is heading somewhere worse.

Run Get-JobScheduleSummary after any schedule change and Get-JobDurationTrends as a routine weekly check alongside job failure monitoring, catching a GROWING trend early is a lot cheaper than fixing a missed maintenance window after the fact.

Comments

Leave a Reply

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