DBA Scripts: Get Last Database Backup Times

🔧Part of the DBA-Tools Project, copy/paste SQL Server scripts and health checks.In: Backup & Recovery

Checking the last backup times is one of the first things worth doing when you sit down at a SQL Server instance, whether it’s one you’ve owned for years or one you inherited an hour ago. Before making changes, running maintenance, or troubleshooting an issue, you need to know whether a reliable recovery point actually exists.

This script shows the most recent full, differential, and log backup for every user database in one pass, so you can see the instance’s backup posture at a glance rather than checking databases one at a time. Note the word user: it filters on database_id > 4, so master and msdb are not in the result. Those two are worth backing up and are the ones most often forgotten on an inherited instance, so check them separately rather than reading a clean report here as an all-clear.


Why Last Database Backup Times Matters

Backups are easy to assume exist and expensive to discover missing:

  • Taking ownership of a new or inherited instance is the moment you most need an honest answer, not an assumed one
  • Investigating recovery options during an incident starts here, before anything else
  • Verifying backup jobs after a change or a reported failure needs a direct check, not a dashboard that might be stale
  • Confirming coverage before maintenance or a migration avoids finding out about a gap at the worst possible time

If a database has no recent full backup, everything else on the list is a secondary priority.


When to Run This Script

  • Routine SQL Server health checks
  • Taking ownership of a new or inherited instance
  • Investigating recovery options during an incident
  • Verifying backup jobs after changes or a reported failure
  • Confirming backup coverage before maintenance or a migration

The Script

Run the following script against your SQL Server instance.

✓ Verified

  • Tested on: SQL Server 2025 (RTM CU8), Windows lab instance
  • Last verified: 2026-08-30 (saved output from a real run, Get-LastDatabaseBackupTimes-20260830-215910.csv)
  • Permissions: db_datareader on msdb
  • Safety: read-only, impact low

Any thresholds in this script are operational heuristics; claim types are labelled where they appear in the text.

/*
Script Name : Get-LastDatabaseBackupTimes
Category    : backups-and-recovery
Purpose     : Display the latest backup timestamp per type (Full, Differential, Log) per database.
Author      : Peter Whyte (https://sqldba.blog/get-last-database-backup-times-in-sql-server/)
Requires    : db_datareader on msdb
HealthCheck : Yes
Notes       : last_log_backup means "the latest log backup that TRUNCATED the log", not simply
              the latest log backup: copy-only log backups are excluded (see the CTE below).
              That is the right signal for "is this log being managed", and it is deliberately
              not the same question as "is this database recoverable to a point in time" — a
              database whose only log backups are copy-only shows NULL here and still has
              restorable log records. Read it alongside Get-RecoveryModelAudit.

              Only ONLINE databases are reported: an offline database cannot be backed up, and
              including it produced a row that looked like a database with no backups at all.
*/
-- SAFE:ReadOnly
-- IMPACT:Low
SET NOCOUNT ON;

WITH latest_backups AS (
    SELECT
        bs.database_name,
        bs.type,
        bs.backup_finish_date,
        CAST(bs.backup_size / 1024.0 / 1024 AS DECIMAL(18,2)) AS backup_size_mb,
        ROW_NUMBER() OVER (
            PARTITION BY bs.database_name, bs.type
            ORDER BY bs.backup_finish_date DESC
        ) AS rn
    FROM msdb.dbo.backupset AS bs
    /* Copy-only LOG backups are excluded, so last_log_backup means "latest log backup that
       truncated the log" rather than "latest log backup". Review-HealthCheckOutput.ps1 turns
       this column straight into the "log will grow unbounded" finding, and a copy-only log
       backup would make that finding vanish while the log kept growing. Copy-only FULL
       backups are kept: a copy-only full is a valid restore base. */
    WHERE bs.type <> 'L' OR bs.is_copy_only = 0
)
SELECT
    d.name AS database_name,
    d.recovery_model_desc,
    MAX(CASE WHEN lb.type = 'D' THEN lb.backup_finish_date END) AS last_full_backup,
    MAX(CASE WHEN lb.type = 'D' THEN DATEDIFF(HOUR, lb.backup_finish_date, GETDATE()) END) AS full_backup_age_hours,
    MAX(CASE WHEN lb.type = 'D' THEN lb.backup_size_mb END) AS full_backup_size_mb,
    MAX(CASE WHEN lb.type = 'I' THEN lb.backup_finish_date END) AS last_diff_backup,
    MAX(CASE WHEN lb.type = 'I' THEN DATEDIFF(HOUR, lb.backup_finish_date, GETDATE()) END) AS diff_backup_age_hours,
    MAX(CASE WHEN lb.type = 'L' THEN lb.backup_finish_date END) AS last_log_backup,
    MAX(CASE WHEN lb.type = 'L' THEN DATEDIFF(HOUR, lb.backup_finish_date, GETDATE()) END) AS log_backup_age_hours
FROM sys.databases AS d
LEFT JOIN latest_backups AS lb
    ON d.name = lb.database_name
   AND lb.rn = 1
WHERE d.database_id > 4
  AND d.state_desc = 'ONLINE'
GROUP BY d.name, d.recovery_model_desc
ORDER BY d.name;

The script returns one row per database, with the most recent full, differential, and log backup timestamps and ages side by side.


How To Run From The Repo

Clone DBA Tools, initialize and run the script:

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

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

# Show last full, differential, and log backup times for all databases:
.\run.ps1 Get-LastDatabaseBackupTimes

# To run against a remote sql server:
.\run.ps1 Get-LastDatabaseBackupTimes -ServerInstance SQLSERVER01

This script lives in the repo at:


Example Output

Run against a lab instance.

SSMS showing the Get-LastDatabaseBackupTimes script and its results grid. Seven databases are listed with their last full, differential and log backup and the age of each in hours. Every differential column is NULL. The top row has a full backup from that morning at 188.09 MB and NULL for both log columns, so its log has never been truncated. The six rows below have a full and a log backup from the same July morning, both around 806 hours old.

The top row has a full backup from that morning and nothing in either log column, so its log has never been truncated. Every other row has a full and a log backup from the same July morning and nothing since, which is a backup schedule that stopped rather than one that never existed. No row here has a differential backup at all.


Understanding the Results

There is no single pass or fail column here. Read the three backup columns together for each database, and read last_log_backup knowing exactly what it counts.

database_name
recovery_model_desc
The database the row is about, and the model that decides how to read the log columns beside it. A SIMPLE database with nothing in last_log_backup is correct and expected, because its log truncates at every checkpoint. A FULL or BULK_LOGGED database with the same gap is the finding below.
last_full_backup
The newest full backup on record. Empty means none exists, so there is nothing to restore and nothing for a log chain to start from. Copy-only fulls are counted here, because a copy-only full is a valid restore base — but that cuts both ways: one ad-hoc copy-only taken this morning will make a scheduled full backup job that died last month look alive. Act when it is empty. Nothing else on the row matters until this does.
last_log_backup
The newest log backup that truncated the log. Copy-only log backups are deliberately excluded, because they preserve the log archive point and do not free any space. Empty on a FULL or BULK_LOGGED database means there is no truncating log backup in this instance’s msdb — which is not quite the same as saying the log has never been truncated. Confirm with log_reuse_wait_desc before calling it a log that will fill the drive. Act when it is empty, the recovery model is FULL or BULK_LOGGED, and nothing on this page explains it away. That is the accidental FULL, and the full backup column can look perfectly healthy beside it.
full_backup_age_hours
log_backup_age_hours
diff_backup_age_hours
Age in hours at the moment you ran the script, not a schedule. An age in the thousands on a daily schedule is a job that stopped, not one running slightly late.
last_diff_backup
Empty simply means differentials are not part of this database’s strategy, which is common and not a finding on its own. It only matters if your restore plan assumes one.
full_backup_size_mb
Size of that last full backup, and the compressed size wherever backup compression is on. A backup that suddenly shrinks by an order of magnitude is worth a look, though the usual answer is that someone enabled compression rather than that anything is wrong, and it feeds the restore window estimate on the duration page.

TipOn an availability group, read this on the replica that takes the backups. msdb.dbo.backupset is local to the instance that ran the backup, not shared across the group. If log backups run on a secondary, the primary shows an empty last_log_backup while truncation is happening perfectly well elsewhere — and connecting through the listener means you may not be looking at the replica you think you are. The same applies after msdb history cleanup or an msdb restore: the backups happened, the rows did not survive.

Best Practices

  • Check this on every new or inherited instance before doing anything else
  • Don’t stop at the full backup column; a FULL recovery model database with no log backups is still exposed
  • Cross-check any surprising result against msdb retention settings, since aggressive history cleanup can make a genuinely backed-up database look worse than it is
  • For a scored, worst-first view instead of a flat list, pair this with Get Backup Coverage, which adds a status flag on top of the same underlying data
  • For full historical detail rather than just the latest backup, pair this with Get Database Backup History

Microsoft’s reference covers backupset and sys.databases in full.


Related Scripts

You may also find these scripts useful:


Frequently Asked Questions

How often should I check last backup times?

As part of every routine health check, and always as a first step on any server you didn’t personally configure. It takes one query and answers the single most important question about a database’s recoverability.

Does a recent full backup mean the database is fully protected?

Not on its own. A FULL recovery model database also needs regular log backups for point-in-time recovery. Check all three columns, full, differential, and log, not just the full backup date.

Why would msdb show no backup for a database I know was backed up?

Backup history in msdb can be pruned by cleanup jobs or maintenance plans. If a result looks wrong, check the actual backup files on disk before assuming the backup never happened.


Summary

A quick “last backup times” check is one of the simplest, highest-value things you can run against a SQL Server instance. It catches most coverage problems early and takes one query to run.

Make it part of your baseline health checks, and treat any missing or unexpectedly old result as something to chase down immediately, not something to note and move past. Recovery is only possible before you find out the hard way, never after.

Comments

Leave a Reply

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