The capacity to automate repetitive tasks is the cornerstone of infrastructure as code, and within the Ansible ecosystem, the ability to iterate over complex data structures—specifically dictionaries—is fundamental for scaling configurations. In the early stages of Ansible's evolution, the with_dict keyword served as the primary mechanism for traversing associative arrays (known as dictionaries or maps). This construct allowed administrators to map a set of keys to their corresponding values, enabling the dynamic creation of users, configuration of virtual hosts, and management of system services without writing redundant tasks.
However, as Ansible evolved toward a more unified and consistent syntax, the community and the developers shifted toward the loop keyword. While with_dict remains functional in many legacy environments, the modern standard since Ansible 2.5 has transitioned to the combination of loop and the dict2items filter. This shift is not merely cosmetic; it represents a fundamental change in how data is piped into a task. By transforming a dictionary into a list of key-value pairs via dict2items, Ansible can utilize a single, consistent looping mechanism across all data types, whether they are simple lists, complex dictionaries, or dynamically generated objects.
The transition from with_dict to loop resolves several critical limitations. Specifically, the old with_dict approach made it exceedingly difficult to filter or transform data before the iteration began. In a modern loop construct, the data undergoes a transformation process (such as filtering with selectattr) before the task ever executes, reducing the reliance on the when conditional and improving the overall efficiency and readability of the playbook.
The Mechanics of with_dict and Traditional Dictionary Looping
The with_dict keyword is specifically designed to iterate over Python dictionaries. In a dictionary, data is stored as a collection of keys, where each key is associated with a value. This is analogous to an associative array in other programming languages. For example, in PHP, a similar operation would be executed using a foreach loop where the key and value are both extracted:
<?php foreach ($users_with_dict as $user => $properties) { // Create a user named $user... } ?>
When using with_dict in an Ansible task, the loop provides two primary variables for each iteration: item.key and item.value. The item.key represents the unique identifier (the dictionary key), and item.value represents the data associated with that key, which could be a simple string or another nested dictionary.
Implementation Example: User Creation via with_dict
To illustrate the practical application of with_dict, consider a scenario where multiple user accounts must be created with specific UIDs and shells.
yaml
- name: Create user accounts
hosts: all
become: true
vars:
users:
alice:
uid: 1001
shell: /bin/bash
bob:
uid: 1002
shell: /bin/zsh
charlie:
uid: 1003
shell: /bin/bash
tasks:
- name: Create users
ansible.builtin.user:
name: "{{ item.key }}"
uid: "{{ item.value.uid }}"
shell: "{{ item.value.shell }}"
with_dict: "{{ users }}"
In this configuration, the with_dict keyword tells Ansible to look at the users variable. In the first iteration, item.key is alice and item.value is a dictionary containing uid: 1001 and shell: /bin/bash. The ansible.builtin.user module then uses these values to configure the system.
Transitioning to Modern Loops: The loop and dict2items Paradigm
Starting with Ansible 2.5, the recommended method for iterating over dictionaries is the loop keyword combined with the dict2items filter. The dict2items filter transforms a dictionary into a list of dictionaries, where each element in the list has a key and a value attribute. This ensures that the behavior remains identical to with_dict while adhering to the modern, consistent looping syntax.
Comparative Analysis of Syntax Evolution
The following table outlines the structural differences between the legacy and modern approaches.
| Feature | Legacy Approach (with_dict) |
Modern Approach (loop + dict2items) |
|---|---|---|
| Primary Keyword | with_dict |
loop |
| Data Transformation | Implicit (Internal to Ansible) | Explicit (via dict2items filter) |
| Variable Access | item.key and item.value |
item.key and item.value |
| Filterability | Limited (requires when conditions) |
High (supports Jinja2 filters like selectattr) |
| Consistency | Specialized loop type | Unified loop system |
The Migration Process in Practice
The migration from with_dict to loop is straightforward because the variable references (item.key and item.value) remain unchanged. The only modification is the replacement of the keyword and the addition of the filter.
Updated User Creation Example
yaml
- name: Create user accounts
hosts: all
become: true
vars:
users:
alice:
uid: 1001
shell: /bin/bash
bob:
uid: 1002
shell: /bin/zsh
charlie:
uid: 1003
shell: /bin/bash
tasks:
- name: Create users
ansible.builtin.user:
name: "{{ item.key }}"
uid: "{{ item.value.uid }}"
shell: "{{ item.value.shell }}"
loop: "{{ users | dict2items }}"
Advanced Data Manipulation and Filtering
One of the most significant advantages of moving to loop is the ability to manipulate the data stream before it reaches the task. With with_dict, filtering usually required a when statement, which means the task would still "run" for every item, even if it was skipped. With loop and Jinja2 filters, you can remove unwanted items from the list entirely before the loop begins.
Filtering with selectattr
Consider a scenario where a dictionary of databases is managed, but only those with replication enabled should be processed.
yaml
- name: Process only databases with replication enabled
ansible.builtin.debug:
msg: "{{ item.key }} has replication"
loop: >-
{{
databases | dict2items
| selectattr('value.replication', 'equalto', true)
| list
}}
vars:
databases:
primary_db:
owner: dbadmin
replication: true
analytics_db:
owner: analyst
replication: false
reporting_db:
owner: reporter
replication: true
In this example, the dict2items filter first converts the dictionary into a list. Then, selectattr is used to filter the list, keeping only those items where the value.replication attribute is true. This results in a cleaner execution log and more efficient processing.
Real-World Application: Managing Virtual Hosts
In a production environment, dictionaries are often stored in group_vars to maintain a separation between logic (playbooks) and data (variables). Managing Nginx or Apache virtual hosts is a prime use case for this architecture.
Variable Definition in group_vars/webservers.yml
yaml
virtual_hosts:
api.example.com:
port: 8080
ssl: true
root: /var/www/api
app.example.com:
port: 3000
ssl: true
root: /var/www/app
static.example.com:
port: 80
ssl: false
root: /var/www/static
Implementation using loop and loop_control
To keep the output clean, the loop_control keyword can be used with the label attribute. Without a label, Ansible prints the entire dictionary for every iteration, which clutters the console. Using label: "{{ item.key }}" ensures that only the domain name is displayed.
yaml
- name: Generate vhost configs
ansible.builtin.template:
src: vhost.conf.j2
dest: "/etc/nginx/sites-available/{{ item.key }}.conf"
loop: "{{ virtual_hosts | dict2items }}"
loop_control:
label: "{{ item.key }}"
Handling Registered Variables during Dictionary Loops
When a loop is registered using the register keyword, Ansible stores the results of every iteration in a list called results. This structure is consistent whether you use with_dict or loop with dict2items.
Registration and Result Processing Example
The following example demonstrates checking database connectivity and then looping through the results to identify failed connections.
```yaml
- name: Check database connectivity
ansible.builtin.command: "pgisready -h {{ item.value.host }} -p {{ item.value.port }}"
loop: "{{ databases | dict2items }}"
register: dbchecks
changed_when: false
- name: Show failed connections
ansible.builtin.debug:
msg: "Cannot reach {{ item.item.key }}"
loop: "{{ db_checks.results }}"
when: item.rc != 0
```
In the second task, item.item.key is used. The first item refers to the result object in the db_checks.results list, and the second .item refers to the original item that was being looped over in the first task.
Limitations and Alternatives for Nested Data
A critical limitation of with_dict (and by extension, the simple loop with dict2items) is that it cannot iterate over sub-elements of a dictionary. If a dictionary contains lists as values, and those lists must be iterated over individually, a different approach is required.
The Challenge of Sub-element Iteration
If you have a dictionary of users, and each user has a list of common directories that need to be created, with_dict cannot be used to create each directory. This is because with_dict iterates at the top level of the dictionary.
Solving Nesting with with_nested
When you need to iterate over two different lists—such as a list of users and a list of directories—with_nested is the appropriate tool. with_nested takes multiple lists and iterates over them in a Cartesian product.
```yaml
vars:
userswithitems:
- name: "alice"
personaldirectories: ["bob", "carol", "dan"]
- name: "bob"
personaldirectories: ["alice", "carol", "dan"]
common_directories:
- ".ssh"
- "loops"
tasks:
- name: "Loop 2: create common users' directories using 'withnested'."
file:
dest: "/home/{{ item.0.name }}/{{ item.1 }}"
owner: "{{ item.0.name }}"
group: "{{ item.0.name }}"
state: directory
withnested:
- "{{ userswithitems }}"
- "{{ common_directories }}"
```
In this construct:
- item.0 refers to the current element of the first list (users_with_items).
- item.1 refers to the current element of the second list (common_directories).
This allows for the creation of paths like /home/alice/.ssh and /home/alice/loops in a single task.
Solving Nesting with with_subelements
For cases where the list to be iterated over is actually a property within the dictionary item itself, with_subelements is used. This allows for a high level of abstraction and readability, which is essential when dealing with complex infrastructure deployments.
Migration Checklist for Infrastructure Engineers
To ensure a seamless transition from legacy with_dict implementations to modern loop structures, the following step-by-step process should be followed:
- Find the
with_dictline in the playbook. - Identify if the dictionary is defined as an inline variable or passed as a variable reference.
- If the dictionary is inline, migrate it to a
varsblock to improve maintainability. - Replace the
with_dict: "{{ dict_var }}"syntax withloop: "{{ dict_var | dict2items }}". - Verify that the
item.keyanditem.valuereferences are still correctly mapped to the intended attributes. - Implement
loop_control: labelif the dictionary values are large, to prevent verbose output in the console. - Check if any filtering is required; if so, replace
whenconditions with Jinja2 filters likeselectattrwithin theloopstatement.
Conclusion: The Strategic Shift toward Declarative Iteration
The evolution from with_dict to the loop and dict2items pattern is more than a change in syntax; it is a shift toward a more powerful, declarative method of handling data in Ansible. By decoupling the data structure from the iteration mechanism, Ansible allows engineers to perform complex transformations—such as filtering and sorting—before the task is even executed.
While with_dict provided a quick way to handle associative arrays, its rigidity limited the ability to manipulate data on the fly. The modern approach provides the flexibility of Python-like list comprehensions within the YAML structure. This allows for a cleaner separation of concerns: variables define the state, filters define the subset of that state to be acted upon, and the loop keyword executes the action.
Moreover, the integration of loop_control and the ability to easily register results into a results list ensures that large-scale deployments remain observable and manageable. Whether managing a handful of virtual hosts or thousands of user accounts across a global fleet, the transition to loop and dict2items provides the scalability and precision required for modern DevOps operations.