When a playbook fails on a template line or a when: condition, guessing variable values wastes time. Debug Ansible playbooks by inspecting runtime data first: print variables with debug, capture module output with register, validate assumptions with assert, check files and services with facts modules, and preview file changes with --check and --diff.
This guide is a practical debugging workflow—not a repeat of inventory, SSH, or sudo diagnosis. If you do not yet know which layer failed, start with the Ansible troubleshooting hub. For full variable precedence or register semantics, see Ansible variables and register and magic variables.
Tested on: Rocky Linux 10.2 (Red Quartz); kernel 6.12.0-211.16.1.el10_2.0.1.x86_64; ansible-core 2.16.16.
~/ansible-project, inventory group lab, and playbooks in playbooks/. Use your own host names and paths if yours differ.
debug, register, assert, facts modules, verbosity, check mode, and diff mode. It does not replace the troubleshooting hub for connectivity errors, the collection and variable errors guide for FQCN issues, or the dedicated facts and template tutorials.
Why Debugging Ansible Playbooks Matters
Ansible failures are easier to fix when you look at actual runtime values instead of rewriting tasks at random. A template error often means a variable is undefined on one host. A when: that never runs may mean a registered key is not where you expect. Check mode shows whether a file task would change the host before you apply it in production.
Debugging helps with idempotency checks, conditionals, templates, facts, and registered output. Use the tools in this guide before you change task logic.
Quick Ansible Debugging Workflow
Work through these steps when a playbook misbehaves:
| Step | What to check | Tool |
|---|---|---|
| 1 | Is playbook syntax valid? | --syntax-check |
| 2 | Are correct hosts targeted? | --list-hosts |
| 3 | Which tasks will run? | --list-tasks |
| 4 | What is the variable value? | debug |
| 5 | What did a task return? | register + debug |
| 6 | Is a file, service, or package present? | stat, service_facts, package_facts |
| 7 | Will this task change the host? | --check --diff |
| 8 | Where exactly does it fail? | --start-at-task, --step, -vvv |
Those CLI flags are documented in how to run Ansible playbooks. For expression bugs inside when:, see operators and tests.
Limit output to one host with -l rocky2 while you debug so the screen stays readable. Avoid -vvv or higher when playbooks pass passwords, tokens, or vaulted variables—verbosity can print module arguments and return data that leak secrets into your terminal and CI logs. Use no_log: true on sensitive tasks and reserve high verbosity for non-secret failures.
Use Verbose Mode with -v, -vvv and -vvvv
Verbosity adds detail without editing the playbook. Increase it when you need to see what Ansible sent to a module or how SSH connected.
| Verbosity | Use |
|---|---|
-v |
Basic extra output; shows which config file Ansible loaded |
-vv |
More task and module detail, including task paths |
-vvv |
Module arguments, return values, and task internals—a good default for task debugging |
-vvvv |
Connection debugging: SSH user, key, port, and remote temp paths |
From the project directory, run a playbook with basic verbosity:
cd ~/ansible-projectansible-playbook playbooks/debug-demo.yml -l rocky2 -vSample output:
Using /home/ansible/ansible-project/ansible.cfg as config file
PLAY [Debug Ansible playbook demo] *********************************************
TASK [Run command] *************************************************************
changed: [rocky2] => {"changed": true, "cmd": ["hostname"], "rc": 0, "stdout": "rocky2", ...}The Using ... ansible.cfg line confirms which configuration file this shell is using—useful when a playbook works locally but fails in CI.
-vvvv and high verbosity can print secrets, tokens, or rendered template content in logs. Avoid piping verbose CI output to public log stores without filtering. For tasks that handle passwords, tokens, or private keys, set no_log: true to hide arguments and results from normal output—and still avoid unnecessary debug tasks around secret values.
Use the debug Module
The ansible.builtin.debug module prints values during execution. Use it to confirm variables, registered results, and expressions before a risky task runs.
Print a simple variable
var: prints the structured value of a variable:
- name: Print a simple variable
ansible.builtin.debug:
var: app_portPrint a custom debug message
msg: accepts Jinja2 expressions in a string:
- name: Print a custom message
ansible.builtin.debug:
msg: "Application will listen on port {{ app_port }}"Print registered task output
After register, print one key or the whole result:
- name: Show command output
ansible.builtin.debug:
var: hostname_result.stdoutPrint debug output only when needed
The verbosity parameter runs the task only when playbook verbosity meets the threshold—handy for noisy detail you do not want on every run:
- name: Print debug output only when verbose mode is used
ansible.builtin.debug:
msg: "Detailed value is {{ app_config }}"
verbosity: 2Run the demo playbook from the project directory:
cd ~/ansible-projectansible-playbook playbooks/debug-demo.yml -l rocky2Sample output:
TASK [Print a simple variable] *************************************************
ok: [rocky2] => {
"app_port": 8080
}
TASK [Print a custom message] **************************************************
ok: [rocky2] => {
"msg": "Application will listen on port 8080"
}
TASK [Print debug output only when verbose mode is used] ***********************
skipping: [rocky2]The verbosity-gated task is skipped at default verbosity. Rerun with -vv and it prints the detailed app_config value.
Remove temporary debug tasks after you fix the playbook—leftover debug clutters logs and may expose sensitive data.
Use register to Capture Task Results
register stores a task's return dictionary in a variable you name. That output drives conditionals, loops, and follow-up debug tasks.
- name: Run command
ansible.builtin.command: hostname
register: hostname_result
- name: Show command output
ansible.builtin.debug:
var: hostname_result.stdoutRegistered variables usually include keys such as changed, failed, rc, stdout, stderr, and stdout_lines. Module-specific keys appear on top of that structure. For loop output and magic variables, see register and magic variables.
Registered variables are host-specific. If a task runs on multiple hosts, each host gets its own hostname_result. When you debug the value, limit to one host with -l rocky2 or the output becomes noisy.
Understand Registered Variable Output
Many guides show register but skip the result shape. These keys appear on most command-style results:
| Key | Meaning |
|---|---|
changed |
Whether Ansible thinks the task changed the host |
failed |
Whether the task failed |
rc |
Return code for command / shell-like tasks |
stdout |
Standard output as a string |
stdout_lines |
Standard output split into lines |
stderr |
Error output |
msg |
Module message |
results |
List of per-item results when using loops |
With -v, the registered command task shows the full hash—including stdout, stderr, and rc—in one line of JSON.
Use assert to Validate Assumptions
ansible.builtin.assert fails early when expressions are false, with a message you control. Use it to validate required variables, port ranges, or OS family before package or service tasks run.
- name: Validate app_port is defined
ansible.builtin.assert:
that:
- app_port is defined
- app_port | int > 0
- app_port | int < 65536
fail_msg: "app_port must be defined and must be a valid TCP port"When validation passes, Ansible reports all assertions succeeded:
TASK [Validate app_port is defined] ********************************************
ok: [rocky2] => {
"changed": false,
"msg": "All assertions passed"
}When a value is out of range, the failure names the failing expression:
TASK [Validate app_port is defined] ********************************************
fatal: [rocky2]: FAILED! => {
"assertion": "app_port | int < 65536",
"changed": false,
"evaluated_to": false,
"msg": "app_port must be defined and must be a valid TCP port"
}Place assertions before risky tasks so obscure template errors do not appear ten steps later.
To gate package or service tasks by platform:
- name: Confirm target is Red Hat family
ansible.builtin.assert:
that:
- ansible_os_family == "RedHat"
fail_msg: "This play supports Red Hat family hosts only"Use stat to Check Files Before Running Tasks
ansible.builtin.stat retrieves file or filesystem status—existence, type, owner, mode, and checksum—without shelling out to test or ls. It supports check mode fully.
- name: Check if config file exists
ansible.builtin.stat:
path: /etc/ssh/sshd_config
register: config_stat
- name: Show file status
ansible.builtin.debug:
var: config_stat.stat.existsSample output:
TASK [Show file status] ********************************************************
ok: [rocky2] => {
"config_stat.stat.exists": true
}Use config_stat.stat.isreg, isdir, or checksum when a copy or service task depends on file state.
Use service_facts to Check Service State
ansible.builtin.service_facts gathers service state as facts—useful before restart or enable tasks.
- name: Gather service facts
ansible.builtin.service_facts:
- name: Show sshd service facts
ansible.builtin.debug:
var: ansible_facts.services['sshd.service']Sample output:
TASK [Show sshd service facts] *************************************************
ok: [rocky2] => {
"ansible_facts.services['sshd.service']": {
"name": "sshd.service",
"source": "systemd",
"state": "running",
"status": "enabled"
}
}Service names follow the unit name on systemd hosts—often httpd.service or sshd.service. Inspect ansible_facts.services selectively; printing the entire dict is rarely useful.
Use package_facts to Check Installed Packages
ansible.builtin.package_facts records installed packages so you can branch without running rpm -q or dnf list.
- name: Gather package facts
ansible.builtin.package_facts:
manager: auto
- name: Check whether openssh is installed
ansible.builtin.debug:
msg: "openssh is installed"
when: "'openssh' in ansible_facts.packages"Package names differ by manager and distribution. Debug the keys under ansible_facts.packages once per platform rather than guessing.
Use --syntax-check, --list-hosts and --list-tasks
These CLI options validate structure before execution. They bridge playbook editing and runtime debugging.
| Option | Use |
|---|---|
--syntax-check |
Validate YAML and playbook structure |
--list-hosts |
Confirm target hosts for each play |
--list-tasks |
Show task order and statically imported tasks |
--list-tags |
List tags when you run plays selectively |
Confirm syntax from the project directory:
cd ~/ansible-projectansible-playbook playbooks/ping-lab.yml --syntax-checkSample output:
playbook: playbooks/ping-lab.ymlList hosts matched by a play:
ansible-playbook playbooks/debug-demo.yml --list-hostsSample output:
play #1 (lab): Debug Ansible playbook demo TAGS: []
pattern: ['lab']
hosts (1):
rocky2List tasks in order:
ansible-playbook playbooks/debug-demo.yml --list-tasksSample output:
play #1 (lab): Debug Ansible playbook demo TAGS: []
tasks:
Print a simple variable TAGS: []
Print a custom message TAGS: []
Run command TAGS: []
...Deeper inventory and host-pattern failures belong in the troubleshooting hub, not here.
Use Check Mode with --check
Check mode runs tasks without making changes where modules support it. Modules report what they would change instead of applying it.
ansible-playbook playbooks/file-demo.yml -l rocky2 --check| Situation | What to know |
|---|---|
| Module supports check mode | Reports predicted changes |
| Module does not support check mode | May skip or return limited data |
| Task must run even in check mode | Set check_mode: false on that task |
| Task should only run in check mode | Set check_mode: true if needed |
command and shell cannot safely predict arbitrary command effects. In check mode, use creates or removes so Ansible can predict whether the command would run; otherwise these tasks may be skipped or return limited information. Prefer state modules such as file, package, copy, or template when testing idempotency—see Ansible idempotency.
Use Diff Mode with --diff
Diff mode shows file content changes for modules such as copy, template, lineinfile, and blockinfile. Combine it with check mode to preview rendered files.
ansible-playbook playbooks/debug-template-demo.yml -l rocky2 --check --diffSample output:
TASK [Deploy application config] ***********************************************
--- before
+++ after: /home/ansible/.ansible/tmp/.../app.conf.j2
@@ -0,0 +1,3 @@
+# demo-app configuration
+listen_port=8080
+host=rocky2
changed: [rocky2]The unified diff shows exactly what the template would write to /tmp/demo-app.conf.
--diff carelessly on templates or files that contain passwords, tokens, private keys, or Vault-rendered secrets—rendered content appears in the log.
Use --start-at-task and --step
After you fix an error mid-playbook, rerun from a named task instead of replaying everything:
ansible-playbook playbooks/debug-demo.yml -l rocky2 --start-at-task "Gather service facts"Ansible still runs fact gathering, then starts at the named task. Task names must match exactly—unique, descriptive name: values make this reliable.
Use --start-at-task only when earlier tasks are already applied or not required for the failing task. If skipped tasks created variables, files, packages, or registered results needed later, the resumed run may fail differently.
--step prompts before each task—useful on long playbooks when you want to confirm state interactively:
ansible-playbook playbooks/debug-demo.yml -l rocky2 --stepDebug Variables, Facts and Templates
For template failures, print inputs immediately before the template task:
- name: Show template inputs
ansible.builtin.debug:
msg:
app_name: "{{ app_name | default('undefined') }}"
app_port: "{{ app_port | default('undefined') }}"
inventory_hostname: "{{ inventory_hostname }}"Use default('undefined') only for optional values—required variables should fail loudly via assert or an undefined error you can trace. Print hostvars or entire ansible_facts only when necessary; filtered keys keep output readable. For template syntax and filters, see template module and Jinja2; for fact gathering depth, see Ansible facts.
Common Debugging Examples
Debug a variable before using it in a task
- name: Confirm port before template
ansible.builtin.debug:
var: app_portDebug output from a command task
- name: Run command
ansible.builtin.command: hostname
register: hostname_result
- name: Show command output
ansible.builtin.debug:
var: hostname_result.stdoutDebug loop results
- name: Create demo directories
ansible.builtin.file:
path: "/tmp/debug-loop-{{ item }}"
state: directory
mode: "0755"
loop:
- alpha
- beta
register: loop_result
- name: Show loop results
ansible.builtin.debug:
var: loop_result.resultsLoop output lives under results—each element has its own item, changed, and failed keys.
Validate required variables with assert
- name: Validate app_port is defined
ansible.builtin.assert:
that:
- app_port is defined
- app_port | int > 0
fail_msg: "app_port must be defined and positive"Check if a file exists before copying
- name: Check if config file exists
ansible.builtin.stat:
path: /etc/myapp/myapp.conf
register: config_stat
- name: Copy default config when missing
ansible.builtin.copy:
src: files/myapp.conf
dest: /etc/myapp/myapp.conf
when: not config_stat.stat.existsPreview template changes with --check --diff
ansible-playbook playbooks/debug-template-demo.yml -l rocky2 --check --diffCommon Mistakes
| Mistake | Why it causes problems |
|---|---|
Printing entire ansible_facts without filtering |
Output becomes huge and hard to read |
| Leaving debug tasks in production playbooks | Logs become noisy and may expose sensitive values |
Using debug instead of fixing variable scope |
Hides the real variable-loading problem |
Running -vvvv in CI logs with secrets |
Verbose output can expose credentials or tokens |
Omitting no_log: true on secret-handling tasks |
Passwords and tokens may appear in playbook output |
Using --diff on secret templates |
Rendered secrets may appear in output |
| Ignoring registered variable structure | Using result.stdout when data is under result.results |
| Debugging all hosts at once | Output becomes unreadable; use -l to limit |
Using command only to check state |
Prefer stat, service_facts, or package_facts where possible |
| Trusting check mode blindly | Some modules do not fully support check mode |
| Making assertions too late | Fail early before risky tasks |
Best Practices
| Practice | Why |
|---|---|
Start with --syntax-check, --list-hosts, and --list-tasks |
Confirms playbook structure before execution |
Limit debugging to one host with -l |
Keeps output readable |
Use debug: var= for variables and registered results |
Prints structured output clearly |
Use register on important tasks |
Makes output reusable for debug and conditions |
Use assert before risky tasks |
Fails early with a useful message |
| Prefer facts modules over shell checks | More idempotent and structured |
Use --check --diff before file or config changes |
Shows planned changes where supported |
| Avoid printing secrets | Protects credentials in logs |
| Remove temporary debug tasks | Keeps playbooks clean |
| Keep task names unique | Helps --start-at-task and log reading |
Summary
Debug Ansible playbooks by inspecting runtime data before you rewrite tasks: print values with debug, capture output with register, validate assumptions with assert, probe state with stat, service_facts, and package_facts, and preview file changes with --check and --diff. Use -vvv for module-level detail and --start-at-task to resume long plays. When the failure is inventory, SSH, or sudo—not variable inspection—use the troubleshooting hub first.
References
- ansible.builtin.debug — print variables during execution
- ansible.builtin.assert — fail when expressions are false
- Check mode — dry-run behavior
- Command line tools — verbosity and playbook options
- Ansible register and magic variables — registered output structure
- Ansible troubleshooting hub — diagnose connectivity and parsing layers first

