Skip to main content
RunBook Academy

AnsibleXXVIII · Plugins, Lookups and FiltersPlugins, lookups and filters

Filters that make data readable

Intermediate⏱ ~22 minansible-docansible-playbook

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

Not yet marked complete on this device.

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:

Read-only / Safeask the controller, not a blog post
$ 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 the

The header line tells you something the option list does not:

Read-only / Safetwo different origins
$ 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:

Read-only / Safethe vars used throughout
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

Read-only / Safemap(attribute=...)
$ ansible-playbook -i localhost, filters.yml
TASK [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

Read-only / Safekeep, and drop
$ ansible-playbook -i localhost, filters.yml
TASK [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 testequalto, 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

Read-only / Safeon a list of scalars
$ ansible-playbook -i localhost, filters.yml
TASK [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:

Read-only / Safeshallow: the whole sub-dictionary is replaced
$ ansible-playbook -i localhost, filters.yml
TASK [combine shallow] *********************************************************
ok: [localhost] => {
  "msg": {
      "ntp": {
          "servers": [
              "b"
          ]
      },
      "tz": "UTC"
  }
}
Read-only / Saferecursive: sub-dictionaries are merged
$ ansible-playbook -i localhost, filters.yml
TASK [combine recursive] *******************************************************
ok: [localhost] => {
  "msg": {
      "ntp": {
          "enabled": true,
          "servers": [
              "b"
          ]
      },
      "tz": "UTC"
  }
}

dict2items and items2dict — cross the dict/list boundary

Read-only / Safea dictionary you can loop over
$ ansible-playbook -i localhost, filters.yml
TASK [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:

Read-only / Safethe idiomatic dictionary loop
- 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:

Read-only / Safeback again
$ ansible-playbook -i localhost, filters.yml
TASK [items2dict] **************************************************************
ok: [localhost] => {
  "msg": {
      "role": "web",
      "tier": "front"
  }
}

Both take key_name and value_name when your data uses different field names:

Read-only / Saferenaming the fields
$ ansible-playbook -i localhost, filters.yml
TASK [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:

Read-only / Safedo not write this
{{ (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:

Read-only / Safethe same expression, broken up
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:

Read-only / Safenamed 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:

Read-only / Safethe same intent, as inventory
# 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

  1. Q1. base is {ntp: {servers: [a], enabled: true}, tz: UTC} and over is {ntp: {servers: [b]}}. What does base | combine(over) produce?

  2. Q2. Why does ansible-doc -t filter ansible.builtin.map print "(Jinja2)" where combine prints a file path?

  3. Q3. A 200-character single-expression filter chain works correctly. Which are genuine reasons to refactor it? Select all that apply.

  4. 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.