Ansible

How to Improve Ansible Performance & Speed Up Playbook Runs

ansible pefromance

🚀 Level Up Your Infrastructure Skills

You focus on building. We’ll keep you updated. Get curated infrastructure insights that help you make smarter decisions.

You’re managing a deployment pipeline that needs to set up 50 servers for a new application release. You start your Ansible playbook, expecting it to finish in a few minutes, but an hour later, it’s still stuck on the first few tasks.

This situation is more common than you might think. Many teams face slow Ansible playbooks that turn quick configuration changes into lengthy bottlenecks. Interestingly, most performance issues arise from a few common problems that are surprisingly easy to fix.

In this article, we’ll explore proven strategies to optimize Ansible playbook execution. We will also discuss a range of methods, including straightforward configuration adjustments that can substantially reduce execution time.

Prerequisites

To follow the examples in this article, you’ll need:

  • A basic understanding of Ansible and the YAML syntax
  • Ansible is installed on your control machine (version 2.9 or later recommended)
  • Access to at least one target server for testing optimisations

1. Use efficient inventory and connection settings

Before diving into advanced optimizations, let’s start with the foundation: how Ansible connects to your servers. Most performance issues begin here, and fortunately, the fixes are straightforward.

SSH pipelining

Ansible’s default SSH settings prioritize compatibility over speed. However, you can safely turn on more aggressive optimizations in most production environments.

One of the most effective ways to enhance performance is by minimizing SSH connection overhead. Ansible establishes multiple SSH connections for each task, which can result in significant delays in high-latency environments. SSH pipelining enhances efficiency by creating a single connection for each host and running multiple commands over it.

Here’s how to configure it:

# ansible.cfg

[ssh_connection]
pipelining = True

These settings work together to create persistent SSH connections that are reused across multiple tasks. Instead of establishing a new connection for each task, Ansible uses a single connection per host, significantly lowering overhead.

SSH connection multiplexing

SSH connection multiplexing improves performance by reusing current SSH connections, minimizing the necessity for new ones. Utilizing a single TCP connection for multiple SSH sessions to the same host diminishes the overhead of establishing and negotiating a new connection each time.

The ssh_args parameter is used to set this up in the ansible.cfg file.

[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=80s

The settings above maintain SSH connections for 80 seconds after use, eliminating the overhead of repeatedly establishing connections to the same hosts. Adjust the ControlPersist value according to the playbook frequency. A value of 60 – 80 seconds will work for short-lived playbooks, and 300 seconds for long-lived playbooks.

Inventory best practices

Poor inventory design can slow down playbook execution. Let’s explore when to use the two most common types of inventory, static and dynamic, and how to use them effectively.

Static inventories offer faster parsing and less overhead for stable, known environments. Dynamic inventories are best used selectively for cloud environments or large, frequently changing infrastructures.

For large static inventories, organize hosts into logical groups and use inventory patterns to target specific subsets:

[webservers]
web01.example.com ansible_host=192.168.1.10
web02.example.com ansible_host=192.168.1.11
web03.example.com ansible_host=192.168.1.12
web04.example.com ansible_host=192.168.1.13
web05.example.com ansible_host=192.168.1.14

[databases]
db01.example.com ansible_host=192.168.2.10
db02.example.com ansible_host=192.168.2.11

[production:children]
webservers
databases

For example, you can test your playbook on a subset of web servers before full production rollout:

# Test on first 2 webservers only
ansible-playbook deploy.yml --limit 'webservers[0:1]'

Implement inventory caching for dynamic inventories to reduce API calls and improve performance:

[inventory]
cache = True
cache_plugin = memory
cache_timeout = 1800

This configuration caches inventory data for 30 minutes, significantly reducing the time spent on inventory resolution for subsequent playbook runs.

Another key advantage of dynamic inventory is that failed or offline hosts are automatically excluded, preventing your playbooks from hanging on unreachable targets.

2. Optimize playbook and task structure

How you organize your playbooks can determine whether they run in a straightforward sequence or achieve real parallel processing. Let’s look at strategies that can turn sluggish playbooks into efficient automation.

Strategy plugins

Ansible’s default linear strategy means all hosts must complete a task before any host can start the next one. This creates unnecessary waiting time when tasks have different execution speeds across hosts.

- hosts: all
  strategy: free
  tasks:
    - name: Update package cache
      apt: update_cache=yes
      
    - name: Install independent packages
      package:
        name: "{{ item }}"
        state: present
      loop:
        - curl
        - wget
        - vim

The free strategy allows faster hosts to continue executing tasks while slower hosts catch up. This is particularly effective when you have a mix of server types or when some hosts have higher latency.

Task grouping with blocks

Organizing related tasks into blocks not only improves readability but also enables better error handling and conditional execution:

- name: Web server configuration
  block:
    - name: Install web server packages
      package:
        name: "{{ item }}"
        state: present
      loop: "{{ web_packages }}"
      
    - name: Configure web server
      template:
        src: httpd.conf.j2
        dest: /etc/httpd/conf/httpd.conf
      notify: restart httpd
      
    - name: Start and enable web server
      service:
        name: httpd
        state: started
        enabled: true
  when: "'webservers' in group_names"

This approach prevents unnecessary task execution on hosts that don’t match the conditions, saving both time and resources.

3. Use asynchronous tasks and parallelism

One of the most effective ways to speed up playbook execution is to run tasks in parallel rather than sequentially. Understanding Ansible’s parallelism options becomes crucial here.

Increase fork count

By default, Ansible employs a conservative limit of five process forks for task execution. However, most control hosts can support many more than this. Raising the fork count enables Ansible to manage multiple hosts simultaneously.

# ansible.cfg
[defaults]
forks = 20

The optimal fork count depends on your control machine’s resources and network capacity. A good starting point is 2-4 times your CPU core count, but monitor system resources and adjust accordingly.

For environments with 50-100 hosts, setting forks to 15-25 typically provides good performance. Larger environments might benefit from 30-50 forks, provided the control node has sufficient resources.

Asynchronous task execution

For long-running tasks, async execution prevents the entire playbook from waiting for a single slow operation:

- name: Long-running backup operation
  command: /usr/local/bin/backup-database.sh
  async: 1800  # 30 minutes timeout
  poll: 0      # Don't wait, continue immediately
  register: backup_job

- name: Continue with other tasks
  package:
    name: monitoring-agent
    state: present

- name: Check backup completion
  async_status:
    jid: "{{ backup_job.ansible_job_id }}"
  register: backup_result
  until: backup_result.finished
  retries: 180  # Check for 30 minutes
  delay: 10     # Check every 10 seconds

This pattern is beneficial for tasks like software installations, database migrations, or file transfers that can run independently.

4. Avoid unnecessary module calls

Every module call adds overhead, so choosing the correct module and minimizing unnecessary operations can significantly impact performance. Selecting the right Ansible modules and utilizing them efficiently is crucial for making playbooks run faster. Too many module calls can really slow things down.

Choose native modules over shell commands

Native Ansible modules are optimized for their specific tasks and often perform better than generic shell commands:

# Inefficient - multiple shell commands
- name: Create directory structure
  shell: |
    mkdir -p /opt/app/config
    mkdir -p /opt/app/logs
    mkdir -p /opt/app/data
    chmod 755 /opt/app/config
    chown app:app /opt/app/data

# Efficient - native modules
- name: Create application directories
  file:
    path: "{{ item.path }}"
    state: directory
    mode: "{{ item.mode | default('0755') }}"
    owner: "{{ item.owner | default('root') }}"
    group: "{{ item.group | default('root') }}"
  loop:
    - { path: '/opt/app/config' }
    - { path: '/opt/app/logs' }
    - { path: '/opt/app/data', owner: 'app', group: 'app' }

Native modules also provide better error handling, idempotency, and cross-platform compatibility.

Check out the list of the most useful Ansible modules.

Batch operations efficiently

When working with multiple similar operations, look for opportunities to batch them:

# Instead of multiple package installations
- name: Install all required packages at once
  package:
    name:
      - nginx
      - postgresql
      - redis
      - nodejs
    state: present

# Instead of multiple file copies
- name: Copy configuration files
  copy:
    src: "{{ item }}"
    dest: "/etc/app/{{ item | basename }}"
    mode: '0644'
  loop:
    - app.conf
    - database.conf
    - cache.conf

5. Tune fact gathering and caching

By default, Ansible gathers facts for each host at the beginning of every play, unless gather_facts is set. Fact gathering can be time-consuming, especially when dealing with multiple hosts, but it’s often unnecessary for simple tasks.

Strategic fact gathering

Control which facts are gathered and when:

# Disable facts for simple tasks
- hosts: all
  gather_facts: no
  tasks:
    - name: Simple service restart
      service:
        name: nginx
        state: restarted

# Gather only specific facts when needed
- hosts: databases
  gather_facts: yes
  gather_subset:
    - "!all"          # Exclude everything
    - "!min"          # Exclude minimal facts
    - network         # Include only network facts
    - hardware        # Include only hardware facts
  tasks:
    - name: Configure database based on memory
      template:
        src: my.cnf.j2
        dest: /etc/mysql/my.cnf
      vars:
        db_memory: "{{ ansible_memory_mb.real.total * 0.7 | int }}MB"

Enable fact caching

Fact caching can dramatically reduce repeated fact-gathering overhead:

# ansible.cfg
[defaults]
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts_cache
fact_caching_timeout = 3600

This is particularly useful in small environments with fewer than 100 servers. However, jsonfile caching requires you to clean up the cache manually. You can take an extra step to automate the cleanup with a scheduled cron job.

# Clean up JSON fact cache older than 7 days daily at 2 AM
0 2 * * * find /tmp/ansible_fact_cache/ -type f -mtime +7 -delete

For production environments, consider using Redis for fact caching:

# ansible.cfg
[defaults]
fact_caching = redis
fact_caching_connection = redis://localhost:6379/0
fact_caching_timeout = 7200 # 120 miutes TTL
fact_caching_prefix = ansible_facts_

Redis caching includes TTL (Time to Live) settings that automatically manage stale cache.

6. Minimise external lookups and Jinja2 overhead

Complex template processing and external lookups can create significant bottlenecks, primarily when performed repeatedly within loops.

Optimise variable processing

Pre-process complex variables outside of loops:

# Inefficient - complex processing in loop
- name: Process configuration files
  template:
    src: "{{ item.name }}.j2"
    dest: "/etc/app/{{ item.name | regex_replace('[^a-zA-Z0-9]', '_') | lower }}.conf"
  loop: "{{ config_files }}"
  vars:
    processed_name: "{{ item.name | regex_replace('[^a-zA-Z0-9]', '_') | lower }}"

# Efficient - pre-process variables
- name: Pre-process configuration names
  set_fact:
    processed_configs: "{{ config_files | map('combine', {'clean_name': item.name | regex_replace('[^a-zA-Z0-9]', '_') | lower}) | list }}"

- name: Deploy configuration files
  template:
    src: "{{ item.name }}.j2"
    dest: "/etc/app/{{ item.clean_name }}.conf"
  loop: "{{ processed_configs }}"

In the example above, we are trying to use names to create a file. The names need to be cleaned (i.e., only letters and numbers, all lowercase).

The inefficient block tries to clean each name on each loop, which is repetitive, harder to maintain, and can ultimately impair performance. The efficient block utilizes set_fact parameter to clean all the names before looping through them.

Cache external lookups

When dealing with external data sources, cache the results:

- name: Fetch external configuration once
  uri:
    url: "{{ config_api_endpoint }}"
    method: GET
    return_content: yes
  register: external_config
  run_once: true
  delegate_to: localhost

- name: Apply configuration to all hosts
  template:
    src: app_config.j2
    dest: /etc/app/config.json
  vars:
    api_config: "{{ external_config.json }}"

In the block above, we fetched data from an external API and used it across all hosts. This prevents repeated HTTP calls, which can slow down the playbook’s execution.

7. Leverage Ansible roles and reuse

Organizing tasks into well-structured roles makes it easier to maintain, allows reuse in different projects, and can boost performance with clearer logic and more efficient condition handling.

Role structure for performance

Using when conditions helps you avoid running installation tasks on systems where they simply won’t work. Instead of executing every task regardless of the target OS, you filter out the ones that don’t apply. 

This saves time during execution and keeps your logs focused on what actually happened, rather than filled with predictable failures from incompatible operations.

# roles/web_server/tasks/main.yml
- name: Include OS-specific variables
  include_vars: "{{ ansible_os_family | lower }}.yml"

- name: Install web server (RedHat family)
  include_tasks: install_redhat.yml
  when: ansible_os_family == "RedHat"

- name: Install web server (Debian family)
  include_tasks: install_debian.yml
  when: ansible_os_family == "Debian"

- name: Configure web server
  include_tasks: configure.yml

Import vs include strategy

Ansible provides two distinct mechanisms for incorporating external task files: import_* and include_*.

Static Imports (import_*) are processed during the parse phase, before playbook execution begins. While the Dynamic Includes (include_*) are processed during runtime, as the playbook executes.

# Static import - processed at parse time (faster)
- import_tasks: always_needed_tasks.yml

# Dynamic include - processed at runtime (more flexible)
- include_tasks: "{{ ansible_distribution | lower }}_specific.yml"
  when: ansible_distribution is defined

Static imports are faster because there is no file I/O during execution; tasks are pre-loaded and ready, and no variable evaluation is needed for filenames. Dynamic includes have overhead because they require file system access during execution, variables must be evaluated at runtime, and there’s additional processing for each include operation.

Static imports are generally faster but less flexible, while dynamic includes offer more control at the cost of some performance.

It is recommended to use imports_* for fixed filenames and always-needed tasks, and include_*  for variable filenames and conditional loading.

8. Monitor and profile playbook runs

Measurement is essential to optimize effectively. By enabling callback plugins like timer, profile_tasks, and profile_roles, you can analyze the time taken by tasks and pinpoint which jobs are causing delays in your plays.

The timer and profile_tasks callback plugins display the start time and duration for every task, while profile_roles provides timing information at the role level. These built-in profiling tools help pinpoint exactly which tasks consume the most execution time.

Enable built-in profiling

 

# ansible.cfg
[defaults]
callbacks_enabled = timer, profile_tasks, profile_roles

This configuration will show you exactly where your playbooks spend their time:

$ ansible-playbook site.yml

PLAY RECAP ********************************************************************
web01.example.com          : ok=12   changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

Monday 05 June 2025  14:30:25 +0000 (0:00:02.45)       0:00:15.20 ************ 
Install packages -------------------------------------------------------- 5.23s
Configure web server ---------------------------------------------------- 3.45s
Start services ---------------------------------------------------------- 2.11s
Update package cache ---------------------------------------------------- 1.89s
Copy configuration files ------------------------------------------------ 1.32s

9. Reduce use of delegate_to, local_action, and run_once

While useful, delegate_to, local_action, and run_once can slow down performance if not used carefully.

These options modify the execution location and behaviour of a task. delegate_to transfers execution to another host (commonly localhost), local_action is a shortcut for executing a task on the control node, and run_once ensures a task is executed only once, typically on the first host.

These are useful when a task needs to run in one place, such as retrieving external data or creating a shared file. However, using them too often, especially in loops, prevents Ansible from running tasks concurrently.

Minimize delegation overhead

Instead of delegating multiple individual tasks:

# Inefficient - multiple delegations
- name: Create local backup directory
  file:
    path: /backups/{{ inventory_hostname }}
    state: directory
  delegate_to: backup-server

- name: Copy application logs
  fetch:
    src: /var/log/app.log
    dest: /backups/{{ inventory_hostname }}/
  delegate_to: backup-server

- name: Compress backup
  archive:
    path: /backups/{{ inventory_hostname }}
    dest: /backups/{{ inventory_hostname }}.tar.gz
  delegate_to: backup-server

Combine operations where possible:

# Efficient - batch operations
- name: Create backup script
  template:
    src: backup.sh.j2
    dest: /tmp/backup_{{ inventory_hostname }}.sh
    mode: '0755'
  delegate_to: backup-server

- name: Execute backup script
  command: /tmp/backup_{{ inventory_hostname }}.sh
  delegate_to: backup-server

Optimize run_once Usage

You can run a task on a single host with run_once, but use it strategically to avoid redundant operations while maintaining parallel execution for other tasks.

For example, you might fetch an API config once with delegate_to: localhost and run_once, then apply that config to all other hosts in parallel:

- name: Fetch config
  uri:
    url: https://api.example.com/app-config
    return_content: yes
  register: config
  run_once: true
  delegate_to: localhost

- name: Apply config to hosts
  template:
    src: app_config.j2
    dest: /etc/myapp/config.json
  vars:
    config_data: "{{ config.json }}"

10. Use efficient loops and data handling

When dealing with large inventories or complex variable sets, how you loop through and organize data can significantly impact execution speed and readability.

Choose the right loop type

Different loop types have different performance characteristics:

# Efficient for simple lists
- name: Install packages
  package:
    name: "{{ item }}"
    state: present
  loop: "{{ package_list }}"

# Efficient for dictionaries
- name: Create users
  user:
    name: "{{ item.key }}"
    groups: "{{ item.value.groups }}"
    shell: "{{ item.value.shell | default('/bin/bash') }}"
  loop: "{{ users | dict2items }}"

# Efficient for complex data structures
- name: Configure services
  service:
    name: "{{ item.name }}"
    state: "{{ item.state }}"
    enabled: "{{ item.enabled | default(true) }}"
  loop: "{{ services }}"
  loop_control:
    label: "{{ item.name }}"  # Reduces verbose output

Optimize data structures

Structure your data in a way that allows for efficient access and processing.

For example, if you’re managing configurations for multiple services like nginx and Apache, organizing them as a dictionary lets you quickly find information based on the service name, without having to search through a list each time.

# Efficient lookup structure
services_config:
  nginx:
    port: 80
    ssl_cert: "/etc/ssl/nginx.crt"
    workers: "{{ ansible_processor_vcpus }}"
  apache:
    port: 8080
    ssl_cert: "/etc/ssl/apache.crt"
    workers: "{{ ansible_processor_vcpus * 2 }}"

# O(1) lookup instead of O(n) search through lists
- name: Configure service
  template:
    src: "{{ service_name }}.conf.j2"
    dest: "/etc/{{ service_name }}/{{ service_name }}.conf"
  vars:
    service_config: "{{ services_config[service_name] }}"

How Spacelift can help you improve Ansible performance

Spacelift’s vibrant ecosystem and excellent GitOps flow are helpful for managing and orchestrating Ansible. By introducing Spacelift on top of Ansible, you can easily create custom workflows based on pull requests and apply any necessary compliance checks for your organization.

Another advantage of using Spacelift is that you can manage infrastructure tools like Ansible, Terraform, Pulumi, AWS CloudFormation, and even Kubernetes from the same place and combine their stacks with building workflows across tools.

You can bring your own Docker image and use it as a runner to speed up deployments that leverage third-party tools. Spacelift’s official runner image can be found here.

Our latest Ansible enhancements solve three of the biggest challenges engineers face when they are using Ansible:

  • Having a centralized place in which you can run your playbooks
  • Combining IaC with configuration management to create a single workflow
  • Getting insights into what ran and where

Provisioning, configuring, governing, and even orchestrating your containers can be performed with a single workflow, separating the elements into smaller chunks to identify issues more easily.

Would you like to see this in action, or just get an overview? Check out this video showing you Spacelift’s Ansible functionality:

ansible product video thumbnail

If you want to learn more about using Spacelift with Ansible, check our documentation, read our Ansible guide, or book a demo with one of our engineers.

Key points

Ansible performance optimization involves making smart changes that lead to big improvements. The most effective strategies focus on efficient connections, running tasks in parallel, and minimizing unnecessary work.

Important optimizations that provide fast results include using SSH pipelining, increasing fork counts, and selectively gathering facts. These simple changes often greatly reduce execution times. We also explored techniques such as asynchronous execution and callback plugins to address specific slowdowns in larger setups.

The examples showed how to apply each optimization, from simple ansible.cfg adjustments to reorganise playbooks for better speed. We explained how organizing roles and handling variables properly helps automation work well across different infrastructure sizes.

Remember that optimization is an ongoing process. As your infrastructure grows and changes, new bottlenecks will emerge. Regular monitoring and profiling should be part of your standard playbook development workflow.

Manage Ansible better with Spacelift

Managing large-scale playbook execution is hard. Spacelift enables you to automate Ansible playbook execution with visibility and control over resources, and seamlessly link provisioning and configuration workflows.

Learn more