Ansible Pitfall: Accessing Variables using vars

tl;dr: vars is an undocumented internal variable with surprising behaviour. Use foo instead of vars['foo'] and lookup('vars', 'foo' ~ bar) instead of vars['foo' ~ bar].

For a long time the only supported way of retrieving variables based on an expression was to use hostvars. This generally works, but not all variables that are in scope are present in hostvars. At some point people learned that you could use a dictionary called vars to access more variables than are present in hostvars[inventory_hostname]:

- hosts: localhost
  vars:
    foo: pisces
  tasks:
    - debug:
        msg: "{{ vars['foo'] }}"

    - debug:
        msg: "{{ hostvars[inventory_hostname]['foo'] }}"
TASK [debug] *******************************************************************
ok: [localhost] => 
    msg: pisces

TASK [debug] *******************************************************************
[ERROR]: Task failed: Finalization of task args for 'ansible.builtin.debug' failed: Error while resolving value for 'msg': object of type 'HostVarsVars' has no attribute 'foo'

Task failed: Finalization of task args for 'ansible.builtin.debug' failed.
Origin: test.yml:8:7

6         msg: "{{ vars['foo'] }}"
7
8     - debug:
        ^ column 7

<<< caused by >>>

Error while resolving value for 'msg': object of type 'HostVarsVars' has no attribute 'foo'
Origin: test.yml:9:14

7
8     - debug:
9         msg: "{{ hostvars[inventory_hostname]['foo'] }}"
               ^ column 14

fatal: [localhost]: FAILED! => {"msg": "Task failed: Finalization of task args for 'ansible.builtin.debug' failed: Error while resolving value for 'msg': object of type 'HostVarsVars' has no attribute 'foo'"}

The problem is that this variable was never intended to be used this way. As such, it exhibits some surprising behaviour that you might not encounter in simple tests but will definitely trip you up at some point. For instance, values in vars are not always templated:

- hosts: localhost
  vars:
    foo: pisces
    bar: "{{ foo[0:2] }}"
  tasks:
    - debug:
        msg: "{{ bar }}"

    - debug:
        msg: "{{ vars['bar'] }}"
TASK [debug] *******************************************************************
ok: [localhost] => 
    msg: pi

TASK [debug] *******************************************************************
ok: [localhost] => 
    msg: '{{ foo[0:2] }}'

Because people are using this in the wild the Ansible developers have not removed this variable, but they have expressed a desire to do so now that variable deprecation is implemented.

In its place they have provided the vars lookup, which can access all variables that are in scope and does not exhibit any of the unexpected behaviour of the vars variable.

- hosts: localhost
  vars:
    foo: pisces
    bar: "{{ foo[0:2] }}"
  tasks:
    - debug:
        msg: "{{ lookup('vars', 'b' ~ 'ar') }}"
TASK [debug] *******************************************************************
ok: [localhost] => 
    msg: pi