Ansible Lesson 14 – Tasks, Handlers and Variables | Dataplexa
Section II · Lesson 14

Tasks, Handlers and Variables

In this lesson

Task attributes Variable scopes Defining variables Handlers & notify register & debug

Tasks, handlers, and variables are the three core building blocks of every Ansible playbook beyond the play header itself. A task is a single call to a module with a set of arguments; a handler is a special task that runs only when explicitly triggered by a change; and a variable is a named value that makes tasks configurable without editing their definitions. Together they enable automation that is not only repeatable but also flexible — the same playbook can configure a development server, a staging environment, and a production fleet by changing nothing but the variables supplied to it.

Task Attributes in Depth

Every task is a YAML mapping. Beyond the required name and module call, tasks accept a rich set of optional attributes that control how and when they execute. These attributes are the difference between a fragile task and a production-grade one.

Key task-level attributes

name Human-readable description. Shown in playbook output. Required — always include it.
when A Jinja2 expression that must evaluate to true for the task to run. If false, the task is skipped. Used for conditional execution based on facts, variables, or previous task results.
register Captures the module's return value into a variable. The variable holds the full JSON result — including changed, stdout, rc, and any data the module returns.
notify Names a handler to trigger if this task reports changed. Multiple tasks can notify the same handler — it runs only once at the end of the play, regardless of how many tasks notified it.
loop Runs the task once for each item in a list. The current item is available as {{ item }}. Replaces the older with_items syntax. Covered in depth in Lesson 16.
become Override the play-level privilege escalation setting for this specific task. Useful when most tasks run as a normal user but one specific task needs root.
ignore_errors When true, a failure in this task does not stop the play. Use sparingly — only when a failure is genuinely expected and handled by subsequent tasks.
tags One or more labels that let you run or skip this task selectively with --tags or --skip-tags at the command line. Covered in Lesson 22.
- name: Install required packages
  ansible.builtin.package:
    name: "{{ item }}"
    state: present
  loop:
    - nginx
    - git
    - curl
  become: true                   # override — this task specifically needs root
  when: ansible_os_family == "Debian"   # only run on Debian-based systems
  tags:
    - packages
    - install
  notify: Restart application    # trigger handler if any package changes

Variables and Scope

Ansible resolves variables through a precedence chain of over 20 levels. In practice, you only need to understand five: the higher a variable is defined in the chain, the higher its priority. A variable defined at the command line always wins; a variable in group_vars is always overridden by a host-level variable for the same key.

HIGHER PRIORITY Highest — Extra vars ansible-playbook site.yml -e "nginx_port=8080" set_fact / registered vars Variables created during the play with set_fact or register Play vars / vars_files vars: block in the play header, or vars_files: referencing a YAML file host_vars host_vars/web01.yml — applies to web01 only Lowest shown — group_vars group_vars/webservers.yml — applies to all webservers hosts LOWER PRIORITY →

The practical rule: if a variable is not behaving as you expect, check which scope defined it and whether a higher-priority source is overriding it. Run ansible-playbook site.yml -e "var=value" to temporarily override any variable without touching a file — useful for debugging.

Defining Variables

Variables can be defined in multiple places. Each serves a different purpose — understanding when to use each location makes your playbooks clean, flexible, and easy to override per environment.

Method 1 — Inline vars: block in the play

---
- name: Deploy application
  hosts: webservers
  vars:
    app_port: 8080            # scoped to this play only
    app_user: appuser
    deploy_dir: /var/www/app

  tasks:
    - name: Create app directory
      ansible.builtin.file:
        path: "{{ deploy_dir }}"
        state: directory
        owner: "{{ app_user }}"
        mode: "0755"

Method 2 — External vars_files:

# In the playbook:
- name: Deploy application
  hosts: webservers
  vars_files:
    - vars/app.yml           # load variables from an external file
    - vars/secrets.yml       # keep secrets in a separate (Vault-encrypted) file

# In vars/app.yml:
---
app_port: 8080
app_user: appuser
deploy_dir: /var/www/app
supported_os: ["Ubuntu", "Debian"]

Method 3 — ansible.builtin.set_fact during a play

- name: Compute derived variables at runtime
  ansible.builtin.set_fact:
    app_url: "http://{{ ansible_default_ipv4.address }}:{{ app_port }}"
    backup_path: "{{ deploy_dir }}/backups/{{ ansible_date_time.date }}"
    # These variables are now available to all subsequent tasks in the play

Method 4 — group_vars/ directory (recommended for most projects)

# File: group_vars/webservers.yml
# Loaded automatically for every host in the webservers group
---
nginx_port: 80
nginx_worker_processes: auto
nginx_client_max_body_size: 10m
app_root: /var/www/html

# File: group_vars/all.yml
# Loaded automatically for EVERY host in the inventory
---
ntp_server: pool.ntp.org
timezone: UTC
log_level: warning

The Settings Menu Analogy

Variable precedence works exactly like a settings menu with multiple levels. The app has factory defaults (group_vars). The user can override those in their profile (host_vars). An administrator can override those with a system policy (play vars). And a developer testing something can override everything with an environment variable (extra vars). The highest-level setting always wins — and you can always trace back which level set the final value.

Handlers and Notify

A handler is a task that only runs when it is explicitly notified by another task that reported changed. Handlers are defined in a separate handlers: block at the play level and execute after all regular tasks have completed — regardless of how many tasks notified them, the handler runs exactly once.

The canonical use case is service restarts: you want Nginx to restart only when its configuration actually changed, not on every playbook run. Without handlers you would either restart on every run (wasteful and potentially disruptive) or never restart automatically (requiring manual intervention). Handlers solve this cleanly.

Install Nginx status: ok Deploy config status: changed ✓ Open firewall status: ok notify Handler: Restart Nginx runs once, after all tasks TASKS (run in order)
---
- name: Configure Nginx
  hosts: webservers
  become: true

  tasks:
    - name: Install Nginx
      ansible.builtin.package:
        name: nginx
        state: present
      # No notify here — installing doesn't need a restart

    - name: Deploy Nginx main configuration
      ansible.builtin.template:
        src: nginx.conf.j2
        dest: /etc/nginx/nginx.conf
        mode: "0644"
      notify: Restart Nginx          # notifies handler IF this task changed

    - name: Deploy virtual host configuration
      ansible.builtin.template:
        src: vhost.conf.j2
        dest: /etc/nginx/conf.d/app.conf
        mode: "0644"
      notify: Restart Nginx          # same handler — still runs only once

    - name: Ensure Nginx is started
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

  # Handlers run AFTER all tasks complete, regardless of how many notified them
  handlers:
    - name: Restart Nginx
      ansible.builtin.service:
        name: nginx
        state: restarted
      # Even if both config tasks changed, Nginx restarts exactly once

What just happened?

Both the main config and the virtual host config tasks notify Restart Nginx. If both change in a single run, the handler is queued twice — but Ansible deduplicates the queue and runs it only once at the end of the play. This prevents double-restarts during deployments where multiple config files change simultaneously.

Capturing Results with register

The register attribute captures a task's full return value into a variable. This is how you pass data between tasks — check a command's output, read a file's content, or use a module's return value in a later when: condition.

- name: Check if app config exists
  ansible.builtin.stat:
    path: /etc/app/config.yml
  register: config_file            # capture the result into 'config_file'

# Use the registered variable in the next task's when condition
- name: Back up existing config before replacing it
  ansible.builtin.copy:
    src: /etc/app/config.yml
    dest: /etc/app/config.yml.bak
    remote_src: true               # copy on the remote host, not from control node
  when: config_file.stat.exists    # only run if the file was found

- name: Deploy new config
  ansible.builtin.template:
    src: config.yml.j2
    dest: /etc/app/config.yml
  notify: Restart application

Inspecting registered variables with ansible.builtin.debug

- name: Get current app version
  ansible.builtin.command:
    cmd: /usr/local/bin/app --version
  register: app_version_result
  changed_when: false              # command doesn't change anything — suppress changed

- name: Print the captured output
  ansible.builtin.debug:
    msg: "Current version: {{ app_version_result.stdout }}"

# You can also dump the entire registered object to see its structure:
- name: Inspect full result structure during development
  ansible.builtin.debug:
    var: app_version_result        # prints the full JSON result object
TASK [Print the captured output] **********************************************
ok: [web01.example.com] => {
    "msg": "Current version: 2.4.1"
}

TASK [Inspect full result structure during development] ***********************
ok: [web01.example.com] => {
    "app_version_result": {
        "changed": false,
        "cmd": "/usr/local/bin/app --version",
        "rc": 0,
        "stdout": "2.4.1",
        "stdout_lines": ["2.4.1"],
        "stderr": "",
        "failed": false
    }
}

What just happened?

The debug module printed both a formatted message using app_version_result.stdout and the full raw result object. During development, dumping the full object with var: is the fastest way to discover what keys a registered variable contains — then you can reference specific fields in subsequent tasks and conditions.

Controlling changed and failed Status

Two task attributes give you precise control over when Ansible considers a task to have made a change or to have failed. changed_when and failed_when are essential when working with command or shell tasks, which do not have built-in idempotency logic.

changed_when
Overrides the module's own changed report
Set to false to suppress spurious changed on read-only commands
Use an expression to declare changed only when the output contains a specific string
failed_when
Overrides Ansible's default failure detection (non-zero exit code)
Mark a task as failed only when the output contains an error string
Ignore a non-zero exit code when the command is expected to exit non-zero sometimes
# changed_when: suppress false positives from read-only commands
- name: Check if migrations are needed
  ansible.builtin.command:
    cmd: python manage.py showmigrations --list
  register: migration_status
  changed_when: false              # this command never changes anything

# changed_when: mark changed only when the output says so
- name: Run database migrations
  ansible.builtin.command:
    cmd: python manage.py migrate
  register: migration_result
  changed_when: "'No migrations to apply' not in migration_result.stdout"

# failed_when: treat a specific non-zero exit as non-fatal
- name: Grep for error in log file
  ansible.builtin.command:
    cmd: grep -c ERROR /var/log/app.log
  register: error_count
  failed_when: error_count.rc > 1  # exit code 1 = no match (not a failure)
  changed_when: false

Handlers Only Run When a Task Reports changed — Not on ok

A frequently misunderstood rule: notify only triggers a handler when the notifying task reports changed. If the task reports ok — because the system was already in the desired state — the handler is never queued. This means if your config file did not change but you need a service restart anyway, you must trigger it manually. Never assume a handler ran just because a task includes a notify directive.

Key Takeaways

Handlers run once at the end of a play — regardless of how many tasks notified them. Multiple config changes in one run trigger only one service restart, not one per changed task.
Variable precedence flows from group_vars up to extra vars — higher in the chain always wins. Use -e at the command line to temporarily override any variable without touching a file.
register captures a task's full return value — use it to pass data between tasks, drive conditional logic, or inspect what a module returned. Use debug: var: to inspect the full object during development.
Use changed_when: false on read-only commands — without it, every command and shell task reports changed on every run, making your play recap meaningless as an idempotency signal.
Prefer group_vars/ and vars_files: over inline vars: — keeping variables out of the play header makes the playbook logic readable and variables easy to override per environment.

Teacher's Note

Add register and debug to a task in the Lesson 13 playbook and inspect the full result object — understanding what a module actually returns is the fastest way to learn what you can do with registered variables in when: conditions.

Practice Questions

1. Three tasks all notify the same handler in a single playbook run and all three report changed. How many times does the handler run?



2. A command task runs a read-only check and always reports changed even though it never modifies anything. What attribute and value should you add to fix this?



3. Which task attribute captures a module's full return value — including stdout, rc, and changed — into a variable for use by subsequent tasks?



Quiz

1. A task that deploys a config file notifies a handler, but on this run the file was already correct and the task reported ok. What happens to the handler?


2. The same variable app_port is defined in group_vars/all.yml, in the play's vars: block, and passed with -e "app_port=9090" at the command line. Which value does Ansible use?


3. What does ansible.builtin.set_fact do?


Up Next · Lesson 15

Facts and Gathering Facts

Discover how Ansible automatically collects hundreds of facts about every managed node — and how to use them to write playbooks that adapt intelligently to any server they run on.