AUTOMATESQL
Back to all guides
October 22, 2026•18 min

Writing Idempotent PowerShell for SQL Server in Ansible

Thumbnail Blog Featured

SqlServerDsc covers most of a standard SQL Server baseline out of the box—memory (SqlMemory), MAXDOP (SqlMaxDop), sp_configure options (SqlConfiguration), trace flags (SqlTraceFlag), and TCP ports (SqlProtocolTcpIp).

Eventually, though, every production build hits settings that have no dedicated SqlServerDsc resource, such as increasing SQL Server Error Log retention (NumErrorLogs) from the default 6 files to 30.

If you drop a raw PowerShell snippet into win_powershell to configure that setting, two things break immediately:

  1. Every playbook run reports changed—even when the server is already set to 30.
  2. Running ansible-playbook --check for a dry-run drift audit also reports changed on every run while silently skipping your PowerShell code altogether.

Here are the three ways to configure custom SQL Server settings idempotently in Ansible. I'll use the NumErrorLogs setting for every example so you can compare them side by side.

Goal
  • Why raw scripts break Ansible audits: what actually happens when you run a standard PowerShell snippet in win_powershell (both normally and with --check).
  • Method 1 — The T-SQL SqlScriptQuery DSC pattern: how GetQuery, TestQuery, and SetQuery load .sql files into memory and enforce idempotency with T-SQL.
  • Method 2 — The DSC Script resource pattern: how GetScript, TestScript, and SetScript work with PowerShell SMO—and where inline Jinja2 string injection gets brittle.
  • Method 3 — The State-Aware win_powershell pattern: how to use parameters:, [CmdletBinding(SupportsShouldProcess)], $Ansible.Changed, $Ansible.Diff, and $Ansible.Result in a single self-contained script.

What Happens When You Run Raw PowerShell in Ansible

Out of the box, SQL Server only keeps 6 error logs before recycling the oldest file.

I don't know which is more frustrating: losing the error log you actually need because an instance gets restarted frequently, or waiting minutes for SSMS to open a single massive error log because the instance has been running for months without cycling it.

One of the first things I configure on every new SQL Server build is increasing error log retention to 30 and adding a scheduled SQL Agent job to cycle the error log daily at midnight.

Because SqlServerDsc doesn't have a SqlErrorLog resource, the tempting shortcut is to call SMO inside win_powershell:

When I run ansible-playbook configure_errorlogs_naive.yml --limit sql1.sandbox.local in my lab, it updates the registry value (HKLM\Software\Microsoft\MSSQLServer\MSSQLServer\NumErrorLogs) to 30.

When I run the exact same playbook a second time, Ansible still reports:

And when I run the playbook in dry-run audit mode (ansible-playbook configure_errorlogs_naive.yml --limit sql1.sandbox.local --check):

Wait. Why did --check report changed when sql1.sandbox.local is already set to 30 error logs?

When I re-run it with -v (ansible-playbook configure_errorlogs_naive.yml --limit sql1.sandbox.local --check -v), look at the JSON output:

By default, ansible.windows.win_powershell assumes any script that finishes without an error modified the server ($Ansible.Changed = $true).

Even worse, during --check mode, if your script does not explicitly declare [CmdletBinding(SupportsShouldProcess)], win_powershell skips executing your PowerShell code entirely ("msg": "skipped, running in check mode") and hardcodes changed: true.

Every dry-run audit reports false-positive configuration drift without ever connecting to SQL Server.

How win_powershell handles --check with and without SupportsShouldProcess
One attribute decides whether --check runs your script or skips it and reports changed

Now that you can recognize the trap, let's look at how you can avoid it, make this task idempotent, and ensure --check returns the correct results.


Method 1: The T-SQL Way (SqlServerDsc + SqlScriptQuery)

If your team prefers pure T-SQL and wants SqlServerDsc to enforce the Get / Test / Set lifecycle without copying .sql files to the target server, SqlServerDsc includes a resource called SqlScriptQuery (alongside its file-based sibling, SqlScript).

Using Ansible's lookup('file', ...) plugin, you can read your .sql scripts directly from the Control Node and pass them into GetQuery, TestQuery, and SetQuery in memory:

How GetQuery, TestQuery, SetQuery, and Variable Work Together

The SqlScriptQuery DSC resource maps three T-SQL queries to PowerShell DSC's three execution phases and passes Ansible variables into Invoke-Sqlcmd using sqlcmd scripting tokens ($(MaxErrorLogs)):

  1. TestQuery (Test-NumErrorLogs.sql) — Runs on Every Playbook Execution (Including --check Mode): SqlScriptQuery executes Test-NumErrorLogs.sql first to decide whether the server is already in the desired state. How does a T-SQL script return $true or $false to DSC? Through SQL error severity:

    • If the script completes without throwing an error (PRINT), Test-TargetResource returns $true and Ansible reports ok.
    • If the script raises an error (RAISERROR(..., 16, 1)), Test-TargetResource catches it, returns $false, and triggers SetQuery.
  2. SetQuery (Set-NumErrorLogs.sql) — Runs Only When TestQuery Fails: When Test-NumErrorLogs.sql raises severity 16 (and Ansible is not in --check mode), SqlScriptQuery executes Set-NumErrorLogs.sql:

  3. GetQuery (Get-NumErrorLogs.sql) — Returns Current State: Queries the current registry value for DSC state inspection (Get-TargetResource). Even though TestQuery makes 100% of the ok vs. changed decision during an Ansible run, SqlScriptQuery marks GetQuery as a mandatory parameter in the DSC schema:

Note

How Variable: Passes Dynamic Values into T-SQL:

Because SqlScriptQuery passes Variable: ["MaxErrorLogs={{ sql_max_error_logs }}"] to Invoke-Sqlcmd -Variable, sqlcmd substitutes $(MaxErrorLogs) inside Test-NumErrorLogs.sql and Set-NumErrorLogs.sql at runtime—so you never have to hardcode 30 inside your .sql files.

The Trade-Off of SqlScriptQuery

SqlScriptQuery (and its file-based counterpart SqlScript) is a great fit when your team wants to keep logic in pure T-SQL or deploy multi-statement SQL scripts like standard maintenance jobs. For a single setting like NumErrorLogs, however, it has two drawbacks:

  1. Three .sql files per setting: Every configuration item requires maintaining Get-*.sql, Test-*.sql, and Set-*.sql (including a mandatory Get-*.sql script whose output Ansible never displays).
  2. No --diff or custom return payload: Because DSC only returns a boolean from Test-TargetResource, it cannot return a before/after property diff ($Ansible.Diff) or custom structured output ($Ansible.Result) to Ansible.

Method 2: The DSC Script Resource (GetScript / TestScript / SetScript)

If you prefer PowerShell SMO over T-SQL xp_instance_regwrite calls, you can use the built-in Windows PSDesiredStateConfiguration Script resource with inline PowerShell blocks for GetScript, TestScript, and SetScript:

While the DSC Script resource runs without staging .sql files on disk and properly supports --check mode via TestScript, look closely at its limitations:

  • Jinja2 string injection ({{ sql_max_error_logs }}): Because the DSC Script resource has no parameters: dictionary, Ansible variables must be injected directly into the raw PowerShell string before execution. For an integer like 30 that looks harmless, but as soon as you pass instance names, file paths, or passwords containing quotes, the PowerShell parser breaks.
  • Duplicated connection settings: GetScript, TestScript, and SetScript execute as separate script blocks, forcing you to repeat the SMO connection setup in all three blocks.
  • No --diff or custom return payload: The Windows DSC Local Configuration Manager (LCM) only returns a boolean from TestScript—it cannot return a before/after property diff ($Ansible.Diff) or custom structured output ($Ansible.Result) to Ansible.

When you want zero temporary files on disk, real typed PowerShell parameters, structured JSON output, and red/green --diff support, use state-aware win_powershell.


Method 3: State-Aware win_powershell (configure_errorlogs.yml)

ansible.windows.win_powershell exposes a built-in $Ansible automatic variable inside your PowerShell session. By following four rules, a single .ps1 file gets the same Get / Test / Set behavior as a DSC resource—running entirely in memory over WinRM or OpenSSH.

Rule 1: Pass Typed parameters: Instead of Jinja2 String Interpolation

Never inject {{ my_var }} directly inside a PowerShell script block. Instead, declare a standard PowerShell param() block at the top of your .ps1 file and pass variables through the win_powershell parameters: dictionary (configure_errorlogs.yml):

Rule 2: Add [CmdletBinding(SupportsShouldProcess)] for --check Mode

When you run ansible-playbook configure_errorlogs.yml --check, win_powershell inspects your script's abstract syntax tree (AST) before running it:

  • Without SupportsShouldProcess: Ansible skips running your PowerShell code ("msg": "skipped, running in check mode") and marks the task changed: true.
  • With [CmdletBinding(SupportsShouldProcess)]: Ansible executes your script and automatically passes -WhatIf:$true into the PowerShell invocation when --check is active.

Rule 3: Test-Before-Set and Gate Changes with $PSCmdlet.ShouldProcess()

Read the live SQL Server state first ($srv.NumberOfLogFiles). Note that when SQL Server has never had its error log limit customized in the registry, SMO returns -1 (meaning the default 6 logs).

Compare the current value to $MaxErrorLogs:

  • If they already match, leave $Ansible.Changed = $false and return immediately.
  • If they do not match, set $Ansible.Changed = $true and wrap the actual $srv.Alter() call inside if ($PSCmdlet.ShouldProcess(...)). During --check mode, ShouldProcess returns $false—so Ansible reports changed ("would change") only when drift actually exists, without modifying SQL Server.

Rule 4: Populate $Ansible.Changed, $Ansible.Diff, and $Ansible.Result

Before reading the full script, click through the four stages it runs on every execution:

### 1. Bind Parameters | CmdletBinding
Declares `[CmdletBinding(SupportsShouldProcess = $true)]` so `--check` runs the script with `-WhatIf`, then starts from `$Ansible.Changed = $false`.
`[CmdletBinding(SupportsShouldProcess = $true)]`
### 2. Get Current State | SMO Read
Connects to the instance through SMO and reads `NumberOfLogFiles`, treating SMO's `-1` as SQL Server's default of 6.
`$rawCurrent = $srv.NumberOfLogFiles`
### 3. Test for Drift | Compare
If the current value already matches `$MaxErrorLogs`, the script returns early and Ansible reports **ok**. If not, it sets `$Ansible.Changed = $true` and records the before/after values in `$Ansible.Diff`.
`if ($rawCurrent -eq $MaxErrorLogs) { return }`
### 4. Gate the Change | ShouldProcess
`$srv.Alter()` runs only inside `$PSCmdlet.ShouldProcess()`. On a normal run SQL Server is updated; under `--check` the call is skipped and the task reports what would change.
`if ($PSCmdlet.ShouldProcess($targetServer, "Set NumberOfLogFiles")) { $srv.Alter() }`

Here is the complete, state-aware [set_num_error_logs.ps1] script putting all four rules together:


Testing All 4 Passes in the Lab (--check --diff, Converge, Re-Run, and Clean --check)

When I run [configure_errorlogs.yml] against sql1.sandbox.local (starting from the default 6 error logs), watch how each pass behaves. Click ▶ Replay to see all four in sequence, then read the full output for each pass below:

===bash
$ ansible-playbook configure_errorlogs.yml --limit sql1.sandbox.local --check --diff
TASK [Ensure SQL Server Error Log retention matches corporate baseline] ********
-    "NumberOfLogFiles": 6
+    "NumberOfLogFiles": 30
changed: [sql1.sandbox.local]

$ ansible-playbook configure_errorlogs.yml --limit sql1.sandbox.local
TASK [Ensure SQL Server Error Log retention matches corporate baseline] ********
changed: [sql1.sandbox.local]

$ ansible-playbook configure_errorlogs.yml --limit sql1.sandbox.local
TASK [Ensure SQL Server Error Log retention matches corporate baseline] ********
ok: [sql1.sandbox.local]

$ ansible-playbook configure_errorlogs.yml --limit sql1.sandbox.local --check
TASK [Ensure SQL Server Error Log retention matches corporate baseline] ********
ok: [sql1.sandbox.local]
[ RESULT ] sql1.sandbox.local : ok=2 changed=0 unreachable=0 failed=0

Pass 1: Dry-Run Audit Detects Drift

(ansible-playbook configure_errorlogs.yml --limit sql1.sandbox.local --check --diff)

Because the script declares [CmdletBinding(SupportsShouldProcess = $true)] and populates $Ansible.Diff, --check --diff executes the SMO read, spots that sql1 is currently at 6 error logs, and prints the exact before/after diff without modifying the server:

Pass 2: Enforce the Baseline

(ansible-playbook configure_errorlogs.yml --limit sql1.sandbox.local)

When I run the playbook without --check, the ShouldProcess block executes $srv.Alter(), updating NumberOfLogFiles to 30:

Pass 3: Re-Run to Prove Idempotency

(ansible-playbook configure_errorlogs.yml --limit sql1.sandbox.local)

When I run it again, the Test phase sees $rawCurrent -eq 30, leaves SQL Server untouched, and reports ok:

Pass 4: Re-Run --check to Prove Clean Drift Audits

(ansible-playbook configure_errorlogs.yml --limit sql1.sandbox.local --check)

Unlike the naive script that reported changed on every --check run, running --check now executes the Test phase against sql1.sandbox.local, confirms NumberOfLogFiles is 30, and reports ok:


What's the Difference Between $Ansible.Changed and $Ansible.Result.Changed?

Look closely at [set_num_error_logs.ps1] and you'll notice it sets both $Ansible.Changed and properties inside $Ansible.Result (Changed, Success, PreviousValue, CurrentValue, Message).

Here is the distinction between the two:

### $Ansible.Changed (top-level property) | ok
- Controls Ansible's native task status: green `ok` or yellow `changed`
- Drives the `changed=1` counter in the final `PLAY RECAP`
- Defaults to `$true`, so set `$Ansible.Changed = $false` when nothing needs to change
---
### $Ansible.Result (custom dictionary) | ok
- Returned to your playbook in the registered variable (`errorlog_result.result.Message`, `errorlog_result.result.PreviousValue`)
- Ansible does **not** inspect `$Ansible.Result.Changed` or `$Ansible.Result.Success` on its own
- Use it for `debug` output, `changed_when:` / `failed_when:`, and downstream `when:` assertions
Tip

Best Practice for Production Playbooks: Set both inside your .ps1 script ($Ansible.Changed / $Ansible.Failed for native Ansible task reporting, and $Ansible.Result for structured output messages and downstream when: assertions). That way your YAML task stays clean without needing extra changed_when: or failed_when: boilerplate code.


Your Next Step

Whenever you hit a SQL Server setting that SqlServerDsc doesn't cover with a dedicated resource:

  • Use SqlServerDsc's SqlScriptQuery resource (GetQuery, TestQuery, SetQuery) when you are deploying multi-statement T-SQL scripts like standard DBA maintenance jobs.
  • Use state-aware win_powershell (parameters:, SupportsShouldProcess, $Ansible.Changed, $Ansible.Diff, and $Ansible.Result) when you want a self-contained, parameterized PowerShell check-and-set task in a single .ps1 file with full --check --diff visibility.
Note

Coming Up Next — Leveling Up from win_powershell to a Custom Ansible Module:

What if you want to reuse this logic across dozens of playbooks without writing script: "{{ lookup('file', ...) }}" and parameters: every time—or what if you're wondering whether you should build a custom PowerShell DSC resource vs. a custom Ansible Windows module?

In an upcoming post, I'll take set_num_error_logs.ps1 and turn it into a first-class win_sql_errorlogs: Ansible module on the Control Node using Ansible.Basic.AnsibleModule—with zero PowerShell modules to install on your target SQL Servers.


References & Further Reading

Share this article:
The Database Platform Automation Roadmap

The Database Platform Automation Roadmap

Move from checklists and scattered scripts to desired-state Ansible playbooks—so you can stop wondering whether production still matches what you built. Includes the 60-second self-audit and 23-page field guide (PDF).