Introduction

Ansible's run_once directive is a deceptively simple feature that can dramatically improve playbook efficiency. While it appears straightforward — "run this task only once" — its interaction with variables, delegation, strategies, and serial execution makes it one of Ansible's most powerful optimization tools.

This guide covers everything you need to know about run_once, from basic usage to advanced patterns that can cut your playbook execution time significantly.

What Does run_once Do?

When you add run_once: true to a task, Ansible executes that task on only one host (the first host in the current batch) instead of running it on every host in the play. The key insight is that results — including registered variables — are still shared with all hosts.

- name: Simple run_once example
  hosts: all
  tasks:
    - name: Get the current date
      ansible.builtin.command: date
      run_once: true
      register: current_date

    - name: Show the date on all hosts
      ansible.builtin.debug:
        var: current_date.stdout

Even though the date command runs on only one host, every host in the play can access current_date.stdout.

Play Level vs Task Level

Play Level

At the play level, run_once is equivalent to targeting only the first host:

- name: Play level run_once
  hosts: host1,host2,host3,host4,host5
  run_once: true
  tasks:
    - name: Print message
      ansible.builtin.debug:
        msg: "Hello World"

This is functionally the same as hosts: host1 or hosts: all[0]. It works, but isn't particularly exciting.

Task Level

The real power of run_once shines at the task level, where you can mix single-execution and multi-host tasks in the same play:

- name: Deploy application
  hosts: web_servers
  tasks:
    - name: Download release artifact (once)
      ansible.builtin.get_url:
        url: "https://releases.example.com/app-{{ version }}.tar.gz"
        dest: /tmp/app-release.tar.gz
      run_once: true
      delegate_to: localhost

    - name: Copy to all servers
      ansible.builtin.copy:
        src: /tmp/app-release.tar.gz
        dest: /opt/app/release.tar.gz

    - name: Extract and deploy
      ansible.builtin.unarchive:
        src: /opt/app/release.tar.gz
        dest: /opt/app/
        remote_src: true

The download happens once, but the copy and deploy run on every host.

run_once with register

One of run_once's best features is how it interacts with register. The registered variable is created for all hosts in the play, not just the host that executed the task:

- name: Database migration
  hosts: app_servers
  tasks:
    - name: Run database migration (only once)
      ansible.builtin.command: /opt/app/migrate.sh
      run_once: true
      register: migration_result

    - name: Show migration status on all hosts
      ansible.builtin.debug:
        msg: "Migration status: {{ migration_result.rc }}"

This behavior is unlike when combined with register, where skipped hosts get an "undefined" or "skipped" result. With run_once, the variable is fully populated on all hosts.

Pseudo-Facts Pattern

The pseudo-facts pattern uses run_once with set_fact to set a variable once that all hosts can use:

- name: Pseudo-facts example
  hosts: all
  tasks:
    - name: Get configuration version from API
      ansible.builtin.uri:
        url: https://config-api.example.com/version
        return_content: true
      run_once: true
      register: config_api

    - name: Set config version fact
      ansible.builtin.set_fact:
        config_version: "{{ config_api.json.version }}"
      run_once: true

    - name: Deploy configuration for this version
      ansible.builtin.template:
        src: "config-{{ config_version }}.j2"
        dest: /etc/app/config.yml

Without run_once, the API call would execute for every host — wasteful if the result is identical. With run_once, one API call serves the entire play.

run_once with delegate_to

Combining run_once with delegate_to is extremely powerful for tasks that should happen on a specific host:

- name: Cluster maintenance
  hosts: cluster_nodes
  tasks:
    - name: Disable cluster health checks
      ansible.builtin.uri:
        url: "http://monitor.example.com/api/maintenance"
        method: POST
        body: '{"cluster": "production", "enabled": true}'
        body_format: json
      run_once: true
      delegate_to: localhost

    - name: Perform rolling update
      ansible.builtin.yum:
        name: myapp
        state: latest

    - name: Re-enable health checks
      ansible.builtin.uri:
        url: "http://monitor.example.com/api/maintenance"
        method: POST
        body: '{"cluster": "production", "enabled": false}'
        body_format: json
      run_once: true
      delegate_to: localhost

run_once with serial

When using serial for batched execution, run_once executes once per batch, not once for the entire play:

- name: Rolling deployment
  hosts: web_servers
  serial: 3
  tasks:
    - name: Log batch start
      ansible.builtin.debug:
        msg: "Starting new batch"
      run_once: true  # Runs once per batch of 3

    - name: Update application
      ansible.builtin.yum:
        name: webapp
        state: latest

If you have 9 hosts with serial: 3, the "Log batch start" task runs 3 times (once per batch).

Strategy Considerations

Linear Strategy (Default)

With the default linear strategy, run_once behaves predictably — the first host in the batch executes the task, and all hosts get the result.

Free Strategy

With strategy: free, hosts execute tasks independently and at their own pace. This means run_once may behave differently:

- name: Free strategy example
  hosts: all
  strategy: free
  tasks:
    - name: Set shared fact
      ansible.builtin.set_fact:
        shared_value: "computed_result"
      run_once: true

With strategy: free, there's no guarantee which host runs first or that the run_once result is available to other hosts before they need it. Avoid run_once with strategy: free unless the task has no cross-host dependencies.

Common Use Cases

One-Time Database Operations

- name: Run database schema update once
  ansible.builtin.command: /opt/app/bin/db-migrate
  run_once: true
  delegate_to: "{{ groups['db_primary'][0] }}"
  register: db_migrate

Sending Notifications

- name: Notify Slack about deployment
  community.general.slack:
    token: "{{ slack_token }}"
    channel: "#deployments"
    msg: "Deploying {{ app_version }} to {{ ansible_play_hosts | length }} hosts"
  run_once: true
  delegate_to: localhost

Gathering External Data

- name: Fetch latest AMI ID
  amazon.aws.ec2_ami_info:
    filters:
      name: "myapp-*"
      state: available
    sort: creation_date
    sort_order: descending
    sort_end: 1
  run_once: true
  delegate_to: localhost
  register: latest_ami

Creating Shared Resources

- name: Create S3 deployment bucket
  amazon.aws.s3_bucket:
    name: "{{ deploy_bucket }}"
    state: present
  run_once: true
  delegate_to: localhost

Best Practices

  1. Use for identical operations — run_once is ideal when the result would be the same regardless of which host runs it
  2. Combine with delegate_to — For API calls, database operations, or cloud resource management
  3. Avoid with strategy: free — Results may not propagate predictably
  4. Remember serial batching — run_once means once per batch, not once per play
  5. Use for expensive operations — API calls, downloads, and database queries benefit most from run_once
  6. Document the intent — Add comments explaining why run_once is used to help future maintainers

Conclusion

The run_once directive is one of Ansible's most effective optimization tools. It reduces redundant operations, simplifies playbook logic, and ensures consistency when tasks need to execute only once across a fleet of hosts.

Key takeaways:

  • Use run_once at the task level for maximum flexibility
  • Registered variables propagate to all hosts automatically
  • Combine with delegate_to for centralized operations like API calls
  • Be aware of serial batching behavior — run_once means once per batch
  • Avoid mixing run_once with strategy: free