5 Common Ansible Anti‑Patterns and How to Fix Them
This article examines five frequent Ansible anti‑patterns—including N+1 loops, overuse of shell commands, uncontrolled fact gathering, deep include nesting, and missing check/diff support—demonstrates their performance impact with real‑world measurements, and provides concrete refactorings, best‑practice guidelines, and a full case study to help engineers write faster, more maintainable playbooks.
Overview
The author inherited a massive Ansible deployment with over 300 tasks and five levels of includes, taking 40 minutes to run. After two weeks of refactoring, execution dropped to 8 minutes with only 80 tasks.
Anti‑Pattern 1: Looping Over Modules (N+1 Problem)
Problem
Running a task that installs each package individually triggers a separate SSH connection and yum transaction for every item.
Performance Test
# Loop installing 10 packages: 127 seconds
# Bulk installing 10 packages: 23 secondsCorrect Approach
Use a single module call with a list of packages, optionally driven by a variable.
# Correct: bulk install
- name: Install required packages
ansible.builtin.yum:
name:
- nginx
- redis
- mysql-server
- python3
- git
- vim
- htop
- iotop
- sysstat
- net-tools
state: presentFor dynamic lists, pass a variable to name. The same principle applies to other modules such as file and user.
Anti‑Pattern 2: Overusing shell / command
Problem
Using shell for tasks like user creation, package installation, file copying, and service management leads to non‑idempotent behavior, always‑changed status, platform dependence, and poor error handling.
Correct Approach
Replace shell commands with dedicated Ansible modules:
# Proper user creation
- name: Create deploy user
ansible.builtin.user:
name: deploy
shell: /bin/bash
create_home: yes
# Proper package installation
- name: Install nginx package
ansible.builtin.yum:
name: nginx
state: present
# Proper file copy
- name: Deploy nginx config
ansible.builtin.copy:
src: nginx.conf
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
notify: Reload nginx
# Proper service management
- name: Ensure nginx is running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: yesIf a shell command is unavoidable, make it idempotent using creates, changed_when, and failed_when parameters, or wrap it in a custom module.
Anti‑Pattern 3: Uncontrolled gather_facts
Problem
Collecting full facts on every play adds a fixed 10‑second delay per host, which multiplies across large inventories.
Performance Test
# Default gather_facts: real 12.347s
# Disabled gather_facts: real 2.891sCorrect Approach
Disable facts when not needed, or limit collection with gather_subset. Example:
# No facts needed
- name: Quick config update
hosts: webservers
gather_facts: no
tasks:
- name: Update nginx config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
# Only network facts needed
- name: Show IP
hosts: all
gather_facts: yes
gather_subset:
- network
tasks:
- name: Show IP
ansible.builtin.debug:
msg: "{{ ansible_default_ipv4.address }}"Fact caching can also be enabled in ansible.cfg to avoid repeated collection.
Anti‑Pattern 4: Deeply Nested include / import
Problem
Excessive nesting makes playbooks hard to read, debug, and slows execution due to repeated file I/O.
Correct Approach
Flatten the structure: keep task files under 100 lines and limit include depth to two levels. Use block to group conditional tasks instead of separate files.
# Example of block usage
- name: CentOS specific tasks
block:
- name: Install EPEL
ansible.builtin.yum:
name: epel-release
state: present
- name: Install CentOS packages
ansible.builtin.yum:
name: "{{ centos_packages }}"
state: present
when: ansible_distribution == 'CentOS'
- name: Ubuntu specific tasks
block:
- name: Update apt cache
ansible.builtin.apt:
update_cache: yes
cache_valid_time: 3600
- name: Install Ubuntu packages
ansible.builtin.apt:
name: "{{ ubuntu_packages }}"
state: present
when: ansible_distribution == 'Ubuntu'Anti‑Pattern 5: Ignoring --check and --diff
Problem
Playbooks that rely on shell commands cannot be safely previewed or rolled back, and they provide no diff output.
Correct Approach
Use idempotent modules that support check mode and diff. When shell is necessary, guard it with when: not ansible_check_mode and set appropriate changed_when flags.
# Git pull with proper module
- name: Pull latest code
ansible.builtin.git:
repo: https://github.com/org/myapp.git
dest: /opt/myapp
version: master
register: git_result
- name: Install dependencies only if code changed
ansible.builtin.pip:
requirements: /opt/myapp/requirements.txt
virtualenv: /opt/myapp/venv
when: git_result.changed
# Shell task guarded for check mode
- name: Run migrations
shell: |
cd /opt/myapp && source venv/bin/activate && python manage.py migrate --noinput
when:
- git_result.changed
- not ansible_check_mode
changed_when: trueTemplates and copy modules automatically support --diff when diff: yes is set.
Refactoring Case Study
The original deploy.yml contained 320 tasks, deep includes, and took 45 minutes. After applying the above principles, the playbook was reduced to 85 tasks, includes were limited to two levels, and total runtime fell to about 8 minutes. The refactored version also added proper gather_facts control, role‑based organization, and post‑run verification.
Best‑Practice Checklist
Give every task a descriptive name .
Prefer fully‑qualified collection names (e.g., ansible.builtin.yum).
Use multi‑line strings with | for shell scripts.
Write complex conditions as a when list.
Enable pipelining and appropriate forks in ansible.cfg for performance.
Run ansible-lint, --syntax-check, and --check --diff before production runs.
Structure roles so that tasks/main.yml is a thin entry point delegating to well‑named task files.
Conclusion
The five anti‑patterns—loop‑based module calls, shell overuse, unchecked fact gathering, deep includes, and missing check/diff support—are common sources of slow, fragile playbooks. By applying the recommended fixes, engineers can achieve faster execution, clearer code, easier debugging, and safer deployments.
MaGe Linux Operations
Founded in 2009, MaGe Education is a top Chinese high‑end IT training brand. Its graduates earn 12K+ RMB salaries, and the school has trained tens of thousands of students. It offers high‑pay courses in Linux cloud operations, Python full‑stack, automation, data analysis, AI, and Go high‑concurrency architecture. Thanks to quality courses and a solid reputation, it has talent partnerships with numerous internet firms.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
