Operations 17 min read

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.

MaGe Linux Operations
MaGe Linux Operations
MaGe Linux Operations
5 Common Ansible Anti‑Patterns and How to Fix Them

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 seconds

Correct 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: present

For 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: yes

If 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.891s

Correct 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: true

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

AutomationDevOpsbest practicesAnsible
MaGe Linux Operations
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.