Introduction
Slow Ansible playbooks waste time and delay deployments. A playbook that takes 30 minutes could run in 5 minutes with the right optimizations. Whether you manage 10 servers or 10,000, performance tuning is essential.
This guide covers 10 proven methods to speed up Ansible playbooks, from quick ansible.cfg tweaks to architectural changes that dramatically reduce execution time.
1. Measure Before Optimizing (Callback Plugins)
Before optimizing, identify what's actually slow. Enable timing callback plugins in ansible.cfg:
[defaults]
callbacks_enabled = timer, profile_tasks, profile_roles
# Optional: set display format
[callback_profile_tasks]
task_output_limit = 20
sort_order = descending
Output example:
PLAY RECAP ****
Wednesday 20 April 2026 22:15:00 +0000 (0:00:02.156) 0:04:23.891 ****
===============================================================================
Install packages ----------------------------------------- 180.23s
Copy configuration files ---------------------------------- 45.12s
Restart services ------------------------------------------ 12.45s
Gather facts ---------------------------------------------- 8.76s
Now you know exactly where to focus your optimization efforts.
2. Disable Fact Gathering
Fact gathering (setup module) runs on every play by default and takes 2-10 seconds per host. If you don't use ansible_facts, disable it:
---
- name: Deploy application
hosts: webservers
gather_facts: false # Save 2-10 seconds per host
tasks:
- name: Copy new artifact
ansible.builtin.copy:
src: app.jar
dest: /opt/app/app.jar
Selective Fact Gathering
If you only need specific facts, gather a subset:
---
- name: Only network facts
hosts: all
gather_facts: true
gather_subset:
- network
- "!hardware"
- "!virtual"
tasks:
- name: Show IP
ansible.builtin.debug:
var: ansible_default_ipv4.address
Fact Caching
If you need facts across multiple plays, cache them instead of re-gathering:
[defaults]
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts_cache
fact_caching_timeout = 3600 # Cache for 1 hour
With gathering = smart, Ansible only gathers facts if they're not in the cache.
3. Increase Parallelism (Forks)
The forks setting controls how many hosts Ansible manages simultaneously. Default is 5 — far too low for most environments:
[defaults]
forks = 50 # Manage 50 hosts in parallel
Guidelines:
| Environment | Recommended Forks |
|---|---|
| Laptop/Dev | 10-20 |
| CI/CD server | 30-50 |
| Dedicated Ansible controller | 50-100 |
| High-memory controller | 100-500 |
Each fork uses ~100-200 MB of memory. Monitor your controller's RAM usage.
4. Enable SSH Pipelining
By default, Ansible opens multiple SSH connections per task (copy module → execute → fetch result). Pipelining reduces this to one connection:
[connection]
pipelining = true
Requirement: The target's /etc/sudoers must have requiretty disabled (it is on most modern systems). If you see errors, add:
Defaults !requiretty
Impact: 2-5x speed improvement for playbooks with many tasks.
5. Optimize SSH Connections (Multiplexing)
SSH multiplexing reuses a single TCP connection for multiple SSH sessions:
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o PreferNetworkAddress=yes
control_path_dir = ~/.ansible/cp
control_path = %(directory)s/%%h-%%r
What this does:
ControlMaster=auto— Reuse existing connectionsControlPersist=60s— Keep connection alive for 60 seconds after last session- Eliminates TCP handshake + SSH authentication for subsequent tasks
6. Use Strategy: free
The default linear strategy waits for ALL hosts to complete each task before moving to the next. The free strategy lets fast hosts race ahead:
---
- name: Deploy with free strategy
hosts: webservers
strategy: free
tasks:
- name: Update packages
ansible.builtin.yum:
name: "*"
state: latest
- name: Restart service
ansible.builtin.systemd:
name: httpd
state: restarted
When to use: Tasks that are independent per host (package installs, restarts). When NOT to use: Tasks that depend on other hosts' state (cluster operations, rolling updates).
7. Use Async for Long-Running Tasks
Long tasks (package updates, database migrations) block all other operations. Use async to run them in the background:
---
- name: Parallel long operations
hosts: all
tasks:
- name: Update all packages (async)
ansible.builtin.yum:
name: "*"
state: latest
async: 600 # Allow up to 10 minutes
poll: 0 # Fire and forget
register: yum_job
- name: Do other quick tasks while packages update
ansible.builtin.copy:
src: motd.txt
dest: /etc/motd
- name: Wait for package update to finish
ansible.builtin.async_status:
jid: "{{ yum_job.ansible_job_id }}"
register: job_result
until: job_result.finished
retries: 60
delay: 10
8. Use Serial for Rolling Updates (Not for Speed, but Control)
While serial limits parallelism (seemingly slower), it prevents downtime during rolling deployments:
---
- name: Rolling deployment
hosts: webservers
serial: "25%" # Deploy to 25% of hosts at a time
tasks:
- name: Deploy new version
ansible.builtin.copy:
src: app-v2.jar
dest: /opt/app/app.jar
notify: Restart app
handlers:
- name: Restart app
ansible.builtin.systemd:
name: myapp
state: restarted
Combine with max_fail_percentage to abort if too many hosts fail:
serial: 5
max_fail_percentage: 20
9. Optimize Task Design
Avoid Loops Where Modules Accept Lists
# SLOW — runs yum 5 times (5 SSH round-trips)
- name: Install packages (slow)
ansible.builtin.yum:
name: "{{ item }}"
state: present
loop:
- nginx
- redis
- postgresql
- python3
- git
# FAST — single yum transaction
- name: Install packages (fast)
ansible.builtin.yum:
name:
- nginx
- redis
- postgresql
- python3
- git
state: present
Use changed_when: false for Check Commands
# Prevents unnecessary handler triggers
- name: Check service status
ansible.builtin.command: systemctl is-active nginx
register: nginx_status
changed_when: false
failed_when: false
Avoid shell When a Module Exists
# SLOW — spawns shell, no idempotency
- name: Create user
ansible.builtin.shell: useradd -m deploy
# FAST — native module, idempotent, no shell overhead
- name: Create user
ansible.builtin.user:
name: deploy
create_home: true
10. Mitogen (Advanced)
Mitogen replaces Ansible's default SSH connection with a high-performance Python-to-Python channel. It can provide 2-7x speedup:
[defaults]
strategy_plugins = /path/to/mitogen/ansible_mitogen/plugins/strategy
strategy = mitogen_linear
Benefits:
- Eliminates temporary file transfers
- Reduces SSH round-trips
- Compresses data in transit
- Connection persistence built-in
Caveat: Not officially supported by Red Hat; test thoroughly before production use.
Quick Reference: ansible.cfg for Performance
[defaults]
forks = 50
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts_cache
fact_caching_timeout = 3600
callbacks_enabled = timer, profile_tasks
host_key_checking = false
internal_poll_interval = 0.001
[connection]
pipelining = true
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
control_path_dir = ~/.ansible/cp
control_path = %(directory)s/%%h-%%r
Performance Impact Summary
| Optimization | Expected Speedup | Effort |
|---|---|---|
| Disable fact gathering | 5-15% | Low |
| Increase forks | 10-50%+ | Low |
| Enable pipelining | 20-50% | Low |
| SSH multiplexing | 10-30% | Low |
| Strategy: free | 20-40% | Low |
| Fact caching | 5-15% | Medium |
| Async tasks | 30-60% | Medium |
| Module lists vs loops | 10-30% | Medium |
| Mitogen | 100-600% | High |
Conclusion
The fastest wins come from combining forks, pipelining, and SSH multiplexing — three ansible.cfg changes that typically cut execution time in half. Add gather_facts: false where possible and pass lists to modules instead of using loops. For maximum performance, evaluate Mitogen for your environment. Always measure with callback plugins before and after changes to verify improvements.
Related Articles
- Ansible async and poll — Background task execution
- Ansible Strategies — Execution strategies explained
- Ansible Configuration File — Full ansible.cfg reference
- Ansible Callback Plugins — Monitoring and profiling