AnsibleXXVIII · Plugins, Lookups and FiltersPlugins, lookups and filters
Filters that make data readable
What you'll learn
- Reshape lists and dictionaries with map, select, selectattr and the dict2items pair
- Distinguish a shallow combine from a recursive one and predict which you need
- Tell an Ansible-provided filter from a Jinja builtin, and know where each is documented
- Refactor an unreadable filter chain into a reviewable form without changing its result
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Part XVII covered the filters that produce a value: the default
family, mandatory, ternary, to_nice_yaml, regex_replace. This
lesson covers the ones that reshape a collection — turning a list of
dictionaries into the three names you wanted, or a dictionary into
something you can loop over.
They are the filters that let inventory data drive configuration
without a pile of intermediate set_fact tasks. They are also the
filters that produce the 200-character expressions nobody can read, so
the second half of the lesson is about writing them so that they
survive review.
Check before you write
Every claim below was verified this way, and you should acquire the habit:
$ ansible-doc -t filter ansible.builtin.dict2items> FILTER ansible.builtin.dict2items (/opt/ansible/venv/lib/python3.12/site-packages/ansible/plugins/filter/dict2items.yml)
Takes a dictionary and transforms it into a list of dictionaries,
with each having a `key' and `value' keys that correspond to the
keys and values of the original.
OPTIONS (red indicates it is required):
_input The dictionary to transform
type: dict
key_name The name of the property on the item representing theThe header line tells you something the option list does not:
$ ansible-doc -t filter ansible.builtin.map | head -8> FILTER ansible.builtin.map (Jinja2)
Applies a filter on a sequence of objects or looks up an attribute.
This is the Jinja builtin filter plugin 'map'.
See:
https://jinja.palletsprojects.com/en/stable/templates/#jinja-filters.map(Jinja2) rather than a file path. map, select, reject,
selectattr, rejectattr and default are Jinja builtins that
Ansible exposes; combine, dict2items, items2dict and mandatory
are Ansible’s own, implemented in plugins/filter/.
That matters twice: it tells you which upstream documentation is
authoritative, and it tells you which behaviours can change under you
with a Jinja upgrade rather than an ansible-core one.
The six
All output below is from a play with this data:
hosts_data:
- { name: web01, env: prod, cpu: 4 }
- { name: web02, env: staging, cpu: 2 }
- { name: db01, env: prod, cpu: 16 }
base: { ntp: { servers: ['a'], enabled: true }, tz: 'UTC' }
over: { ntp: { servers: ['b'] } }
tags_map: { role: web, tier: front }map — pull one attribute out of every item
$ ansible-playbook -i localhost, filters.ymlTASK [map attribute] ***********************************************************
ok: [localhost] => {
"msg": [
"web01",
"web02",
"db01"
]
}{{ hosts_data | map(attribute='name') | list }}
The trailing | list is the near-universal idiom, and it is worth
knowing exactly why — see the note at the end of this lesson, because
what it does on ansible-core 2.21 is not what most documentation says
it does.
selectattr and rejectattr — filter by an attribute
$ ansible-playbook -i localhost, filters.ymlTASK [selectattr] **************************************************************
ok: [localhost] => {
"msg": [
"web01",
"db01"
]
}
TASK [rejectattr] **************************************************************
ok: [localhost] => {
"msg": [
"web02"
]
}{{ hosts_data | selectattr('env', 'equalto', 'prod') | map(attribute='name') | list }}
{{ hosts_data | rejectattr('env', 'equalto', 'prod') | map(attribute='name') | list }}
The second argument is the name of a test — equalto, match,
search, in, defined, version. Lesson 6 covers tests properly;
here, note that selectattr('env') with no test is also valid and
means “keep items whose env attribute is truthy”, which is a
different question and a common accident.
select and reject — filter a plain list
$ ansible-playbook -i localhost, filters.ymlTASK [select on plain list] ****************************************************
ok: [localhost] => {
"msg": "[1, 3, 5] and [2, 4]"
}{{ [1, 2, 3, 4, 5] | select('odd') | list }}
{{ [1, 2, 3, 4, 5] | reject('odd') | list }}
The attr suffix is the whole difference: select tests the item,
selectattr tests an attribute of the item.
combine — merge dictionaries, shallowly or not
This is the one with a trap, and it is worth seeing both results side by side:
$ ansible-playbook -i localhost, filters.ymlTASK [combine shallow] *********************************************************
ok: [localhost] => {
"msg": {
"ntp": {
"servers": [
"b"
]
},
"tz": "UTC"
}
}$ ansible-playbook -i localhost, filters.ymlTASK [combine recursive] *******************************************************
ok: [localhost] => {
"msg": {
"ntp": {
"enabled": true,
"servers": [
"b"
]
},
"tz": "UTC"
}
}dict2items and items2dict — cross the dict/list boundary
$ ansible-playbook -i localhost, filters.ymlTASK [dict2items] **************************************************************
ok: [localhost] => {
"msg": [
{
"key": "role",
"value": "web"
},
{
"key": "tier",
"value": "front"
}
]
}This is how you loop over a dictionary, which loop: cannot do
directly:
- name: Apply each tag
ansible.builtin.lineinfile:
path: /etc/app/tags
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
loop: "{{ tags_map | dict2items }}"
loop_control:
label: "{{ item.key }}"items2dict is the inverse, and the round trip is lossless:
$ ansible-playbook -i localhost, filters.ymlTASK [items2dict] **************************************************************
ok: [localhost] => {
"msg": {
"role": "web",
"tier": "front"
}
}Both take key_name and value_name when your data uses different
field names:
$ ansible-playbook -i localhost, filters.ymlTASK [dict2items renamed] ******************************************************
ok: [localhost] => {
"msg": [
{
"Key": "role",
"Value": "web"
}
]
}That option is how you feed AWS-style Key/Value tag structures
without hand-building them.
Making it readable
Here is a real shape of expression that appears in mature repositories:
{{ (groups['web'] | map('extract', hostvars) | selectattr('ansible_distribution_major_version', 'equalto', '12') | selectattr('app_role', 'defined') | map(attribute='inventory_hostname') | list) | difference(groups['maintenance'] | default([])) | sort }}It works. It is also a maintenance defect, and the reason is not
aesthetic: nobody can review it. A reviewer cannot tell whether the
selectattr chain is in the right order, whether difference is
subtracting the right way round, or what happens if app_role is
defined but empty. So it gets approved on the basis that the author
tested it.
Three techniques, in increasing order of preference.
1. YAML block scalars for line breaks. A >- folded scalar lets
you break a long expression across lines:
eligible_hosts: >-
{{ groups['web']
| map('extract', hostvars)
| selectattr('ansible_distribution_major_version', 'equalto', '12')
| selectattr('app_role', 'defined')
| map(attribute='inventory_hostname')
| list
| difference(groups['maintenance'] | default([]))
| sort }}Better. Each step is on its own line and a reviewer can read the pipeline top to bottom. Still one expression, so an intermediate value cannot be inspected.
2. Named intermediate variables. vars are lazily evaluated and
can refer to each other, so a chain becomes a sequence of named steps:
vars:
web_hostvars: "{{ groups['web'] | map('extract', hostvars) | list }}"
bookworm_hosts: "{{ web_hostvars | selectattr('ansible_distribution_major_version', 'equalto', '12') | list }}"
with_app_role: "{{ bookworm_hosts | selectattr('app_role', 'defined') | list }}"
in_maintenance: "{{ groups['maintenance'] | default([]) }}"
eligible_hosts: >-
{{ (with_app_role | map(attribute='inventory_hostname') | list)
| difference(in_maintenance) | sort }}Now each name says what the step means, debug can print any of them
during diagnosis, and a reviewer can check one line at a time.
The trailing | sort is not cosmetic: difference is set-based and
does not preserve input order — ['a','b','c'] | difference(['b'])
returns ['c', 'a'] on this version. Any host list you intend to
compare between runs, or read in a recap, needs an explicit sort.
3. Ask whether it belongs in the inventory instead. The expression above computes a group. Ansible has a thing for that:
# inventory/constructed.yml
plugin: ansible.builtin.constructed
strict: false
groups:
eligible: >-
ansible_distribution_major_version == '12'
and app_role is defined
and inventory_hostname not in groups.get('maintenance', [])hosts: eligible in the play, and the selection logic lives where
selection logic belongs — visible to ansible-inventory --graph, which
means you can see the host list before you run. Part XXIX covers the
constructed plugin; Part XXX covers why seeing the host list first is
the blast-radius habit.
Knowledge check
Knowledge check · 4 questions
Q1. base is {ntp: {servers: [a], enabled: true}, tz: UTC} and over is {ntp: {servers: [b]}}. What does base | combine(over) produce?
Q2. Why does ansible-doc -t filter ansible.builtin.map print "(Jinja2)" where combine prints a file path?
Q3. A 200-character single-expression filter chain works correctly. Which are genuine reasons to refactor it? Select all that apply.
Q4. On ansible-core 2.21, omitting | list after map(attribute='name') breaks length, indexing and reuse of the result.
Passing score: 75%. Answers are checked in this browser.