Introduction

Monitoring Microsoft SQL Server database files is an important part of infrastructure supervision.
A database reaching its storage limit can rapidly lead to application outages, transaction failures, or even complete service interruptions.

The Initial Problem

In SQL Server, retrieving file information is relatively simple when working directly inside SQL Server Management Studio

  • database file size,
  • used space,
  • free space,
  • auto-growth configuration,
  • or maximum file size

A common query used by many DBAs is the following:

USE [TEST]

GO

SELECT
  DB_NAME(database_id) AS DBName,
  Name AS Logical_Name,
  Physical_Name AS [PhysicalName],
  CAST(ROUND(((size)/128/1024), 2) AS float) AS [SizeGB],
  CAST(ROUND((CAST(FILEPROPERTY(name, 'SpaceUsed') AS INT)/128/1024), 2) AS float) AS [SpaceUsedGB],
  CAST(ROUND((size/128/1024 - CAST(FILEPROPERTY(name, 'SpaceUsed') AS INT)/128/1024), 2) AS float) AS [FreeSpaceGB],
  ROUND(ISNULL(((CAST((fileproperty(name, 'SpaceUsed'))/128/1024 as float)) / NULLIF((CAST(size/128/1024 as float)), 0)), 0)*100, 2) as [SpaceUsed%],
  CASE WHEN [max_size] = -1 THEN [max_size] ELSE CAST(ROUND(((max_size)/128/1024), 2) AS float) END AS [MaxSizeGB],
  CAST(ROUND((max_size/128/1024 - CAST(FILEPROPERTY(name, 'SpaceUsed') AS INT)/128/1024), 2) AS float) AS [FreeSpaceGBtoMaxSize],
  ROUND(ISNULL(((CAST((fileproperty(name, 'SpaceUsed'))/128/1024 as float)) / NULLIF((CAST(max_size/128/1024 as float)), 0)), 0)*100, 2) as [SpaceUsed%toMaxSize]
FROM sys.master_files
WHERE DB_NAME(database_id) = 'TEST'
ORDER BY Logical_Name

The initial query works perfectly for manual analysis in SQL Server Management Studio.
However, integrating it into Zabbix becomes more complicated because sys.database_files only returns information from the current database context.

This means the query must be executed individually for every database using:

USE [DatabaseName]

In small environments this remains manageable, but in larger infrastructures containing dozens or even hundreds of databases, maintaining static queries quickly becomes impractical.

To automate this process, we use:

sp_MSforeachdb

This SQL Server procedure dynamically executes the same query across every database on the instance.

Example:

EXEC sp_MSforeachdb '
USE [?]

SELECT
    DB_NAME(),
    name,
    physical_name
FROM sys.database_files
'

The ? placeholder is automatically replaced by the database name during execution, allowing SQL Server to iterate through all databases dynamically.

At first glance, this seems to solve the problem entirely.
However, another limitation quickly appears: sp_MSforeachdb returns one independent result set per database.

Instead of producing a single structured dataset, SQL Server generates multiple separate tables:

Database 1 result
-----------------
file1
file2

Database 2 result
-----------------
file1
file2

While this output remains perfectly readable for a DBA inside SQL Server Management Studio, it becomes difficult to exploit inside Zabbix, especially for Low-Level Discovery and dependent items.

To solve this limitation, we redesigned the query architecture to centralize all results into a single temporary table, allowing Zabbix to consume one normalized dataset fully compatible with automatic discovery and scalable monitoring.

Why Zabbix Cannot Easily Use This Output

Zabbix works much better when:

  • the result is normalized,
  • the structure is predictable,
  • and all rows belong to a single dataset.

With multiple independent result sets:

  • Low-Level Discovery becomes difficult,
  • JSON conversion becomes complicated,
  • dependent items cannot parse values correctly,
  • preprocessing becomes unreliable.

The issue is therefore no longer the database context itself. The issue becomes the query output structure.

Building a Zabbix-Compatible Query

To make the output usable inside Zabbix, the query had to be redesigned completely.

The objective was no longer simply to retrieve MSSQL file information, but to transform multiple independent database results into a single normalized dataset compatible with Low-Level Discovery.

The new query therefore introduces three important concepts:

  • dynamic iteration through all databases using sp_MSforeachdb,
  • centralized data collection using a global temporary table,
  • and a final unified output consumable by Zabbix dependent items.

The core logic becomes inside the sp_MSforeachdb execution.

INSERT INTO ##Results

Each database inserts its rows into the same centralized structure instead of returning independent result sets.

At the end of the execution, the query simply returns:

SELECT * FROM ##Results

The final output is now normalized instead of multiple disconnected tables.

DatabaseFileTypeSizeFree
TESTTEST_DataData
TESTTEST_LogLog
PRODPROD_DataData

The complete query

SET NOCOUNT ON;

DECLARE @Granularity VARCHAR(10) = NULL
DECLARE @Database_Name sysname = NULL
 
DECLARE @SQL VARCHAR(5000)

IF EXISTS (SELECT NAME FROM tempdb..sysobjects WHERE NAME = '##Results')
BEGIN
    DROP TABLE ##Results
END

CREATE TABLE ##Results (
    [Database Name] sysname,
    [File Name] sysname,
    [Physical Name] NVARCHAR(260),
    [File Type] VARCHAR(4),
    [Total Size in Bytes] BIGINT,
    [Available Space in Bytes] BIGINT,
    [Growth Units] VARCHAR(20),
    [Max File Size in Bytes] BIGINT
)

SELECT @SQL =
'USE [?] 
INSERT INTO ##Results(
    [Database Name],
    [File Name],
    [Physical Name],
    [File Type],
    [Total Size in Bytes],
    [Available Space in Bytes],
    [Growth Units],
    [Max File Size in Bytes]
)
SELECT 
    DB_NAME(),
    [name],
    physical_name,
    CASE type
        WHEN 0 THEN ''Data''
        WHEN 1 THEN ''Log''
    END,

    -- TOTAL SIZE (bytes) SAFE
    CAST(size AS BIGINT) * 8 * 1024,

    -- FREE SPACE (bytes) SAFE
    (CAST(size AS BIGINT) - CAST(FILEPROPERTY([name], ''SpaceUsed'') AS BIGINT)) * 8 * 1024,

    -- GROWTH
    CASE is_percent_growth
        WHEN 1 THEN CAST(growth AS varchar(20)) + ''%''
        ELSE CAST(CAST(growth AS BIGINT) * 8 * 1024 AS varchar(20))
    END,

    -- MAX SIZE (bytes) SAFE
    CASE max_size
        WHEN -1 THEN NULL
        WHEN 268435456 THEN NULL
        ELSE CAST(max_size AS BIGINT) * 8 * 1024
    END

FROM sys.database_files
ORDER BY type, file_id
'

EXEC sp_MSforeachdb @SQL

-- =========================
-- RESULTATS
-- =========================

IF @Database_Name IS NULL
BEGIN
    IF @Granularity = 'Database'
    BEGIN
        SELECT
            T.[Database Name],

            T.[Total Size in Bytes] AS [DB Size (Bytes)],
            T.[Available Space in Bytes] AS [DB Free (Bytes)],
            T.[Consumed Space in Bytes] AS [DB Used (Bytes)],

            D.[Total Size in Bytes] AS [Data Size (Bytes)],
            D.[Available Space in Bytes] AS [Data Free (Bytes)],
            D.[Consumed Space in Bytes] AS [Data Used (Bytes)],
            CEILING(CAST(D.[Available Space in Bytes] AS decimal(20,2)) / NULLIF(D.[Total Size in Bytes],0) * 100) AS [Data Free %],

            L.[Total Size in Bytes] AS [Log Size (Bytes)],
            L.[Available Space in Bytes] AS [Log Free (Bytes)],
            L.[Consumed Space in Bytes] AS [Log Used (Bytes)],
            CEILING(CAST(L.[Available Space in Bytes] AS decimal(20,2)) / NULLIF(L.[Total Size in Bytes],0) * 100) AS [Log Free %]

        FROM
        (
            SELECT [Database Name],
                SUM([Total Size in Bytes]) AS [Total Size in Bytes],
                SUM([Available Space in Bytes]) AS [Available Space in Bytes],
                SUM([Total Size in Bytes] - [Available Space in Bytes]) AS [Consumed Space in Bytes]
            FROM ##Results
            GROUP BY [Database Name]
        ) T

        INNER JOIN
        (
            SELECT [Database Name],
                SUM([Total Size in Bytes]) AS [Total Size in Bytes],
                SUM([Available Space in Bytes]) AS [Available Space in Bytes],
                SUM([Total Size in Bytes] - [Available Space in Bytes]) AS [Consumed Space in Bytes]
            FROM ##Results
            WHERE [File Type] = 'Data'
            GROUP BY [Database Name]
        ) D ON T.[Database Name] = D.[Database Name]

        INNER JOIN
        (
            SELECT [Database Name],
                SUM([Total Size in Bytes]) AS [Total Size in Bytes],
                SUM([Available Space in Bytes]) AS [Available Space in Bytes],
                SUM([Total Size in Bytes] - [Available Space in Bytes]) AS [Consumed Space in Bytes]
            FROM ##Results
            WHERE [File Type] = 'Log'
            GROUP BY [Database Name]
        ) L ON T.[Database Name] = L.[Database Name]

        ORDER BY T.[Database Name]
    END
    ELSE
    BEGIN
        SELECT
            [Database Name],
            [File Name],
            [Physical Name],
            [File Type],
            [Total Size in Bytes] AS [DB Size (Bytes)],
            [Available Space in Bytes] AS [DB Free (Bytes)],
            CEILING(CAST([Available Space in Bytes] AS decimal(20,2)) / NULLIF([Total Size in Bytes],0) * 100) AS [Free Space %],
            [Growth Units],
            [Max File Size in Bytes] AS [Grow Max Size (Bytes)]
        FROM ##Results
    END
END
ELSE
BEGIN
    IF @Granularity = 'Database'
    BEGIN
        SELECT
            T.[Database Name],

            T.[Total Size in Bytes] AS [DB Size (Bytes)],
            T.[Available Space in Bytes] AS [DB Free (Bytes)],
            T.[Consumed Space in Bytes] AS [DB Used (Bytes)],

            D.[Total Size in Bytes] AS [Data Size (Bytes)],
            D.[Available Space in Bytes] AS [Data Free (Bytes)],
            D.[Consumed Space in Bytes] AS [Data Used (Bytes)],
            CEILING(CAST(D.[Available Space in Bytes] AS decimal(20,2)) / NULLIF(D.[Total Size in Bytes],0) * 100) AS [Data Free %],

            L.[Total Size in Bytes] AS [Log Size (Bytes)],
            L.[Available Space in Bytes] AS [Log Free (Bytes)],
            L.[Consumed Space in Bytes] AS [Log Used (Bytes)],
            CEILING(CAST(L.[Available Space in Bytes] AS decimal(20,2)) / NULLIF(L.[Total Size in Bytes],0) * 100) AS [Log Free %]

        FROM
        (
            SELECT [Database Name],
                SUM([Total Size in Bytes]) AS [Total Size in Bytes],
                SUM([Available Space in Bytes]) AS [Available Space in Bytes],
                SUM([Total Size in Bytes] - [Available Space in Bytes]) AS [Consumed Space in Bytes]
            FROM ##Results
            WHERE [Database Name] = @Database_Name
            GROUP BY [Database Name]
        ) T

        INNER JOIN
        (
            SELECT [Database Name],
                SUM([Total Size in Bytes]) AS [Total Size in Bytes],
                SUM([Available Space in Bytes]) AS [Available Space in Bytes],
                SUM([Total Size in Bytes] - [Available Space in Bytes]) AS [Consumed Space in Bytes]
            FROM ##Results
            WHERE [File Type] = 'Data'
              AND [Database Name] = @Database_Name
            GROUP BY [Database Name]
        ) D ON T.[Database Name] = D.[Database Name]

        INNER JOIN
        (
            SELECT [Database Name],
                SUM([Total Size in Bytes]) AS [Total Size in Bytes],
                SUM([Available Space in Bytes]) AS [Available Space in Bytes],
                SUM([Total Size in Bytes] - [Available Space in Bytes]) AS [Consumed Space in Bytes]
            FROM ##Results
            WHERE [File Type] = 'Log'
              AND [Database Name] = @Database_Name
            GROUP BY [Database Name]
        ) L ON T.[Database Name] = L.[Database Name]

        ORDER BY T.[Database Name]
    END
    ELSE
    BEGIN
        SELECT
            [Database Name],
            [File Name],
            [Physical Name],
            [File Type],
            [Total Size in Bytes] AS [DB Size (Bytes)],
            [Available Space in Bytes] AS [DB Free (Bytes)],
            CEILING(CAST([Available Space in Bytes] AS decimal(20,2)) / NULLIF([Total Size in Bytes],0) * 100) AS [Free Space %],
            [Growth Units],
            [Max File Size in Bytes] AS [Grow Max Size (Bytes)]
        FROM ##Results
        WHERE [Database Name] = @Database_Name
    END
END

DROP TABLE ##Results

Discovery in Zabbix

Once the query has been implemented in Zabbix, the next step is to create a Low-Level Discovery (LLD) rule in order to automatically discover databases and files, then generate the associated dependent items and triggers dynamically.

A dedicated blog explaining how to configure Low-Level Discovery in Zabbix is available here: Create DISCO Put the link here

Conclusion

Monitoring MSSQL database files in Zabbix can become challenging when working with multiple databases and dynamic environments.
While the initial query is perfectly suitable for manual analysis, its structure and dependency on the current database context make it difficult to integrate directly into Zabbix.

By using sp_MSforeachdb together with a centralized temporary table, we can transform multiple independent result sets into a single normalized dataset fully compatible with Zabbix Low-Level Discovery and dependent items.

This approach provides a scalable and reusable solution capable of automatically monitoring database and log file growth across an entire SQL Server instance while significantly reducing manual configuration and maintenance efforts.