How to return a variable from within a block to a higher scope

Hi everyone,

Apologies for what may be a very simple question, but I feel like I’m going in circles. I have a role that lists available updates in Linux. All I want is for that data to be returned to the playbook that calls the role. According to this post on Stackoverflow the variable should be accessible, but it isn’t because it is registered within a block. Here’s my playbooks:

# Playbook that calls the role
---
- name: Update machines using the default configuration
  hosts: linux
  become: true
  roles:
    - role: ayvens.update_roles.list_linux_updates
    # generates update metadata in list_linux_updates_updatable_packages
  tasks:
    - name: List available package updates (From playbook)
      ansible.builtin.debug:
        var: list_linux_updates_updatable_packages.stdout
# Role
---
- name: List updates
  block:
    - name: Updates for RedHat/SUSE and derived distributions
      when: ansible_facts.pkg_mgr == 'dnf'
      block:
        - name: Gather available package updates for RedHat/SUSE systems
          ansible.builtin.command:
            cmd: dnf check-update --quiet
          register: list_linux_updates_updatable_packages
          changed_when: list_linux_updates_updatable_packages.rc in [0, 100]
          failed_when: list_linux_updates_updatable_packages.rc not in [0, 100]

        - name: List available package updates (Inside dnf block)
          ansible.builtin.debug:
            var: list_linux_updates_updatable_packages.stdout

    - name: Updates for Debian/Ubuntu and derived distributions
      when: ansible_facts.pkg_mgr == 'apt'
      block:
        - name: Gather available package updates for Debian/Ubuntu systems
          ansible.builtin.command:
            cmd: apt-get upgrade --dry-run
          register: list_linux_updates_updatable_packages
          changed_when: list_linux_updates_updatable_packages.rc == 0

- name: List available package updates (From role)
  ansible.builtin.debug:
    var: list_linux_updates_updatable_packages.stdout

As you can see, I have done some debugging with three prints. Only the one from inside the dnf block works. From what I’ve read and observed, this variable is restricted to the scope of the block in which it is defined. But I need it on a much higher scope. So, I need to either:

  1. Parse the variable “up the chain” somehow, or
  2. Register the variable on a higher scope (but sadly register doesn’t work on a block, which makes sense)

I’ve looked for methods to change the scope of variables, take their values outside of blocks, alternative methods of defining them, but I was unable to find what I need. Any input is much appreciated!

You should use set_fact, see

ansible.builtin.set_fact module – Set host variable(s) and fact(s). — Ansible Community Documentation

This will set whatever variables you want as host facts, so you can later use that variable.
At the end of the block add

        - name: Set fact to "return" variable from role (block)
          ansible.builtin.set_fact:
            list_linux_updates_updatable_packages: "{{ list_linux_updates_updatable_packages }}"

Setting a=a, should not confuse you, as regitered variables and variables that are set with set_facts are a bit different, despite them being on the same precendence level, see

3 Likes

Hey,

The set_fact approach is right. The reason register behaves this way is that it creates a task-scoped variable tied to where it ran. Inside a block, that scope collapses once the block finishes. set_fact is different because it writes to the host’s fact cache, which persists for the entire play and crosses role boundaries.

One thing worth knowing: the set_fact: a: “{{ a }}” pattern works when the block succeeds, but if your command task fails and goes to rescue, the registered variable may not exist yet, and referencing it in set_fact will give you an undefined variable error. A safer pattern is to initialize before the block and use set_fact in both the block body and any rescue handling:

- name: Initialize updates list
  ansible.builtin.set_fact:
    list_linux_updates_updatable_packages: []

- block:
    - name: Check available updates
      ansible.builtin.command: dnf check-update --quiet
      register: update_check
      changed_when: update_check.rc == 100
      failed_when: update_check.rc not in [0, 100]

    - name: Promote result to host fact
      ansible.builtin.set_fact:
        list_linux_updates_updatable_packages: "{{ update_check.stdout_lines }}"

  rescue:
    - name: Mark check as failed
      ansible.builtin.set_fact:
        list_linux_updates_updatable_packages: []

This way the calling playbook always gets a defined variable regardless of whether the update check succeeded or failed. Worth doing if your calling playbook uses the variable in a when condition or loops over it.

Best of luck with it.

1 Like

That’s simply not true. Registered variables are neither task-scoped nor affected by blocks in the slightest. (You can even access them from a different play entirely, which sometimes surprises people.)

- hosts: localhost
  tasks:
    - command: echo foo
      register: result

    - block:
        - command: echo bar
          register: result2

        - debug:
            msg: "{{ result.stdout }} / {{ result2.stdout }}"

    - debug:
        msg: "{{ result.stdout }} / {{ result2.stdout }}"

- hosts: localhost
  tasks:
    - debug:
        msg: "{{ result.stdout }}"
PLAY [localhost] ***************************************************************

TASK [command] *****************************************************************
changed: [localhost]

TASK [command] *****************************************************************
changed: [localhost]

TASK [debug] *******************************************************************
ok: [localhost] =>
    msg: foo / bar

TASK [debug] *******************************************************************
ok: [localhost] =>
    msg: foo / bar

PLAY [localhost] ***************************************************************

TASK [debug] *******************************************************************
ok: [localhost] =>
    msg: foo
6 Likes

No, they both work.

The problem is, they both work!

As was pointed out by others above, blocks have no affect on registered variables. Blocks allow you to specify some things at the block level that get applied to all the tasks inside the block. Beyond that and the rescue: thing, they aren’t “scoping” anything in the sense that programmers expect from block-structured languages.

The debug inside the block “works” because you just registered the variable and there’s something to display. No mystery there.

Then the trouble starts. You have another task (which happens to be inside a block, which is irrelevant), which you “skip” (because the when: condition isn’t met). But you’re registering the same variable in this skipped task. The registered result of a skipped task looks like this:

changed: false
false_condition: false
skip_reason: Conditional result was False
skipped: true

Because you’re registering your results to the same variable — even when the task is skipped — you’re wiping out your first registration. You can do a couple of things:

  • put your dnf and apt tasks into separate task files, then include the appropriate tasks files for the target system, or
  • register the dnf and apt results to different variables, then deal with one of them containing skipped: true.

Here’s a complete example if anyone wants to play with this phenomenon.

---
- name: Register tests
  hosts: localhost
  gather_facts: false
  tasks:
    - name: Echo and register "foo" when true
      ansible.builtin.command: echo foo
      when: true
      register: my_only_variable

    - name: Display my_only_variable the 1st time
      ansible.builtin.debug:
        msg: '{{ my_only_variable }}'

    - name: Echo and register "bar" when false
      ansible.builtin.command: echo bar
      when: false
      register: my_only_variable

    - name: Display my_only_variable the 2nd time
      ansible.builtin.debug:
        msg: '{{ my_only_variable }}'

And the job log copy-n-pasted from my terminal.

PLAY [Register tests] **************************

TASK [Echo and register "foo" when true] *******
changed: [localhost]

TASK [Display my_only_variable the 1st time] ***
ok: [localhost] => 
  msg:
    changed: true
    cmd:
    - echo
    - foo
    delta: '0:00:00.002123'
    end: '2026-04-04 12:53:54.017933'
    failed: false
    msg: ''
    rc: 0
    start: '2026-04-04 12:53:54.015810'
    stderr: ''
    stderr_lines: []
    stdout: foo
    stdout_lines:
    - foo

TASK [Echo and register "bar" when false] ******
skipping: [localhost]

TASK [Display my_only_variable the 2nd time] ***
ok: [localhost] => 
  msg:
    changed: false
    false_condition: false
    skip_reason: Conditional result was False
    skipped: true

PLAY RECAP *************************************
localhost                  : ok=3    changed=1    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0
3 Likes

The reason is that scope of variables is not documented anywhere. I would argue that scope in which variable is accessible is much more important than variable precedence in certain use cases.

Very good, example, Thank you @utoddl!
I would also add that never ever one should register or set_fact variables with the same name. There might be some use cases where is makes sense, but I don’t have any from the top of my mind. Will be grateful if someone point out examle to me.
The reason this should not be allowed is that this makes ansible rewrite variable value, breaking immutability.

The reason that variable scope is not documented is because variables aren’t scoped. They have precedence, and you can have multiple variables with the same name but different precedences and different values. But that isn’t scoping, at least not in the sense that programmers think of scoping.

I don’t have any problem with identically named variables for facts and/or registrations - provided the precedence implications are intentional. These things have a defined behavior; work with it, not against it like the original poster was attempting to do. Unique names may avoid some surprises, but they may also preclude some elegant solutions. Above all, make it easy to maintain for the next person (who might be you).

1 Like

There’s vars: for tasks (and blocks, as a special case of tasks), which is the only thing that’s properly scoped.

Then there’s the DEFAULT_PRIVATE_ROLE_VARS setting (Ansible Configuration Settings — Ansible Community Documentation), which allows to scope role variables to the role they’re defined in. But this isn’t enabled by default, and can’t be enabled per role, but only globally (and then potentially breaks existing playbooks which exploit that roles usually bleed variables).

There is an important use-case: if you use register: to store the result of sensitive module output like the one of community.crypto.openssl_privatekey_pipe, which returns a private key, then you’d better overwrite it with ansible.builtin.set_facts afterwards before it stays in global context and is accidentally leaked somewhere further down the role / playbook.

If there would be a scoped register:, this wouldn’t be necessary. (You can also use another register: to overwrite it, obviously…)

Practical example: check out the “Create SOPS-encrypted private key” playbook in Protecting Ansible secrets with SOPS — Ansible Community Documentation

4 Likes

Yes, it is. It could be improved and doesn’t really get into the edge cases like private role variables, but the variable documentation lays out the basic scopes.

Registered variables are host-scoped.

1 Like

Here is what I mean by variable scope (brief explanation, there are number of caveats to this that will be omitted):

  1. Command-line values (for example, -u my_user, these are not variables)
    Scoped only to the current run, next time this variable might not be accessible. If not set.

  2. Role defaults (as defined in Role directory structure) 1
    Scoped only to the role. If public is not set to true for the role ansible.builtin.include_role module – Load and execute a role — Ansible Community Documentation

  3. Inventory file or script group vars 2
    Scoped to the inventory file (script), only if respective inventory file is in ansible-playbook parameters

  4. Inventory group_vars/all 3
    Are in scope only if respective inventory file is in ansible-playbook parameters

  5. Playbook group_vars/all 3
    Scoped only to playbook

  6. Inventory group_vars/* 3
    Are in scope only if respective inventory file is in ansible-playbook parameters and host is in respective group

  7. Playbook group_vars/* 3
    Scoped only to playbook and if host is in respective group

  8. Inventory file or script host vars 2
    Scoped to the inventory file (script), only if respective inventory file is in ansible-playbook parameters and to resective host.

  9. Inventory host_vars/* 3
    Are in scope only if respective inventory file is in ansible-playbook parameters and respective host

  10. Playbook host_vars/* 3
    Scoped only to playbook and to respective host

  11. Host facts and cached set_facts 4
    Only after the fact has been set / collected and only for the host it has been set / collected

  12. Play vars
    Scoped only to single play

  13. Play vars_prompt
    Available only after the prompt and in one play (not sure here)

  14. Play vars_files
    Scoped only to single play

  15. Role vars (as defined in Role directory structure)
    Scoped only to the role. If public is not set to true for the role ansible.builtin.include_role module – Load and execute a role — Ansible Community Documentation

  16. Block vars (for tasks in block only)
    Scoped to block

  17. Task vars (for the task only)
    Scoped to task

  18. include_vars
    Only after include_vars action. This is also scope, compare to role vars/defaults that are in scope in any place “inside” role.

  19. Registered vars and set_facts
    Only after variable has been registered / set and only for the host it has been registered set. But across many plays inside playbook.

  20. Role (and include_role) params
    Scoped only to the role. Disregardpublic parameter for include_role

  21. include params
    Only “inside” include_role and include_tasksactions

  22. Extra vars (for example, -e "user=my_user")(always win precedence)
    Scoped only to the current run, next time this variable might not be accessible.

Not sure that I am 100% right on all of the points. This is my understanding. To be honest I do not have experience with all variable precendences levels.
Conclution: there are many scopes, and some variables are available only in intersection of them for instance inventory file and hostname. Scope of some variables can be changed dynamically - role defaults/vars with public parameter of include_role
Scope determins visibility of variables. How variable is defined determins where this variable and its value are accessible.
As one can see there are lexical and dynamic scopes in ansible. Or lexically and dynamically scoped variables. And this creates some confusion from my point of view.

Lexical scope (also called static scope) means that the visibility of variables is determined by where they are written in the code (their physical/lexical location), not by how the program executes.
Dynamic scope - variables are resolved based on the call stack at runtime, not where functions are defined.
Also lexical and dynamic scopes are interacting with one another as in ansible there are variable precendence rules and it is not possible to read variable of lower precedence when high precendence variable is defined.

Lexical scope example - inventory variables
Dynamical scope examples - variables set by set_facts

1 Like

This is quote from that link

Reading this one might think that there are only three scopes in ansible - global, play and host. Let’s put this to test.

Example 1
In what scope variable 'findme` is?
global, play or host?

- name: In what scope variable 'findme` is?
  hosts: host1
  gather_facts: false
  tasks:
    - name: Debug
      ansible.builtin.debug:
      var: findme

inventory file

all:
  children:
    group1:
      hosts:
        host1:
    group2:
      vars:
        findme: youfoundme
      hosts:
        host2:

Example 2
In what scope variable 'findme` is?
global, play or host?

- name: In what scope variable 'findme` is?
  hosts: host1
  gather_facts: false
  tasks:
    - name: Debug
      ansible.builtin.debug:
      var: findme
    - name: Set variable
      ansible.builtin.set_fact:
        findme: youfoundme

Example 3
In what scope variable 'findme` is?
global, play or host?

- name: Play 1
  hosts: host1
  gather_facts: false
  tasks:
    - name: Set variable
      ansible.builtin.set_fact:
        findme: youfoundme
- name: Play 2
  hosts: host1
  gather_facts: false
  tasks:
    - name: Debug
      ansible.builtin.debug:
      var: findme

Example 4
In what scope variable 'findme` is? (see how I changed hosts in the second play)
global, play or host?

- name: Play 1
  hosts: host1
  gather_facts: false
  tasks:
    - name: Set variable
      ansible.builtin.set_fact:
        findme: youfoundme
- name: Play 2
  hosts: **host2**
  gather_facts: false
  tasks:
    - name: Debug
      ansible.builtin.debug:
      var: findme

:slight_smile: If that’s the brief explanation, I’m afraid I’m not ready for the full exposition! I’m already suffering from “scope creep” in this thread.

But if it helps anyone understand the subtleties of Ansible variables, that’s fantastic. More power to them.

Personally, I don’t find “scope” to be a particularly helpful concept here. That is, if it always has to be qualified by source, visibility/precedence, and lifetime anyway, then I’m happy sticking with those terms.

When I started learning Ansible, I briefly tried (and failed) to shoehorn Ansible variables into my notions of scope brought in from other block-structured languages. Who knew Dante was an Ansible user when he wrote, “Abandon scope, all ye who enter here.

2 Likes

Hi everyone, my apologies for taking some time to get back on this. I have taken the discussions to heart and finally figured out the way to get the result I want with the feedback, but it’s not exactly perfect as it has some duplication. It’s annoying that I cannot use the same code twice because specifying the same variable name twice is problematic, but c’est la vie I suppose.

Below is the solution I worked out based on the discussions above. I am aware that it doesn’t have any error catching in case list_linux_updates_updatable_packages is undefined, but thankfully, due to the nature of how it is filled, I do not see a possibility of it being empty in my use-case.

# Main playbook that calls the role
---
- name: Update machines using the default configuration
  hosts: linux
  become: true
  roles:
    - role: ayvens.update_roles.list_linux_updates
    # generates update metadata in list_linux_updates_updatable_packages
  post_tasks:
    - name: List available package updates (From playbook)
      ansible.builtin.debug:
        var: list_linux_updates_updatable_packages
# Role playbook
---
- name: List updates
  block:
    - name: Updates for RedHat/SUSE and derived distributions
      when: ansible_facts.pkg_mgr == 'dnf'
      block:
        - name: Gather available package updates for RedHat/SUSE systems
          ansible.builtin.command:
            cmd: dnf check-update --quiet
          register: list_linux_updates_updatable_packages_raw_RH
          changed_when: list_linux_updates_updatable_packages_raw_RH.rc in [0, 100]
          failed_when: list_linux_updates_updatable_packages_raw_RH.rc not in [0, 100]
          notify:
            - Write RedHat packages to variable

    - name: Updates for Debian/Ubuntu and derived distributions
      when: ansible_facts.pkg_mgr == 'apt'
      block:
        - name: Gather available package updates for Debian/Ubuntu systems
          ansible.builtin.command:
            cmd: apt-get upgrade --dry-run
          register: list_linux_updates_updatable_packages_raw_DB
          changed_when: list_linux_updates_updatable_packages_raw_DB.rc == 0
          notify:
            - Write Debian packages to variable
# Handlers in role
---
- name: Write RedHat packages to variable
  ansible.builtin.set_fact:
    list_linux_updates_updatable_packages: "{{ list_linux_updates_updatable_packages_raw_RH.stdout_lines }}"

- name: Write Debian packages to variable
  ansible.builtin.set_fact:
    list_linux_updates_updatable_packages: "{{ list_linux_updates_updatable_packages_raw_DB.stdout_lines }}"

I’d love to find a way to have the handler not duplicated, but for now I am happy that I have a working solution. Many thanks to everyone!

You can get rid of the handlers, the set_facts, and possibly the role if you’re willing to use the shell module instead of the command module. Behold:

    - name: Gather available packages via apt or dnf
      ansible.builtin.shell: |
        case "{{ ansible_facts.pkg_mgr }}" in
          dnf) dnf check-update --quiet ;;
          apt) apt-get upgrade --dry-run ;;
        esac
      register: list_linux_updates_updatable_packages
      changed_when: list_linux_updates_updatable_packages.rc in [0, 100]
      failed_when: list_linux_updates_updatable_packages.rc not in [0, 100]

People will bend themselves out of shape to avoid ansible.builtin.shell, but it isn’t evil. Decide for yourself which method you’d rather maintain.

-------- Edit --------
Actually it’s quite easy to do this with the command module like this:

    - name: Gather available package via apt or dnf
      ansible.builtin.command:
        cmd: "{{ cmd[ansible_facts.pkg_mgr] }}"
      vars:
        cmd:
          apt: apt-get upgrade --dry-run
          dnf: dnf check-update --quiet
      register: list_linux_updates_updatable_packages
      changed_when: list_linux_updates_updatable_packages.rc in [0, 100]
      failed_when: list_linux_updates_updatable_packages.rc not in [0, 100]

Cheers!

2 Likes

In Case if due to some “XYZ” reason, you don’t want to change the structure of your play-book, then you can follow this approach

  • Use different names for the registered variables
  • set_fact from the registered that is not skipped.

role

---
- name: List updates
  block:
  - name: Updates for RedHat/SUSE and derived distributions
    when: ansible_facts.pkg_mgr == 'dnf'
    block:
    - name: Gather available package updates for RedHat/SUSE systems
      ansible.builtin.command:
        cmd: dnf check-update --quiet
      register: list_linux_updates_updatable_packages_dnf
      changed_when: list_linux_updates_updatable_packages_dnf.rc in [0, 100]
      failed_when: list_linux_updates_updatable_packages_dnf.rc not in [0, 100]
  - name: Updates for Debian/Ubuntu and derived distributions
    when: ansible_facts.pkg_mgr == 'apt'
    block:
    - name: Gather available package updates for Debian/Ubuntu systems
      ansible.builtin.command:
        cmd: apt-get upgrade --dry-run
      register: list_linux_updates_updatable_packages_apt
      changed_when: list_linux_updates_updatable_packages_apt.rc == 0
- set_fact:
    list_linux_updates_updatable_packages: >-
      {% if list_linux_updates_updatable_packages_dnf is not skipped %}
      "{{ list_linux_updates_updatable_packages_dnf.stdout }}"
      {% elif list_linux_updates_updatable_packages_apt is not skipped %}
      "{{ list_linux_updates_updatable_packages_apt.stdout }}"
      {% endif %}
...

play-book calling the role.

---
- name: Update machines using the default configuration
  hosts: all
  become: true
  roles:
    - role: ayvens.update_roles.list_linux_updates
    # generates update metadata in list_linux_updates_updatable_packages
  tasks:
    - name: List available package updates (From playbook)
      ansible.builtin.debug:
        var: list_linux_updates_updatable_packages
...
1 Like