Introduction

The throttle keyword in Ansible limits how many hosts can execute a specific task at the same time, regardless of the forks setting. While forks controls the global parallelism and serial controls batch size for plays, throttle gives per-task concurrency control. Use it to prevent overloading a shared resource like a database, API endpoint, load balancer, or license server during automation.

Syntax

- name: Task with throttle
  ansible.builtin.command: do-something
  throttle: 3  # Only 3 hosts run this task simultaneously

throttle vs serial vs forks

Setting  | Scope     | Controls                        | Set Where
─────────|───────────|─────────────────────────────────|──────────────
forks    | Global    | Max parallel host connections   | ansible.cfg
serial   | Play      | Batch size (hosts per wave)     | Play level
throttle | Task      | Max concurrent for ONE task     | Task level
# Example: 100 hosts, forks=50, serial=20, throttle=3 on one task
#
# Play runs in batches of 20 hosts (serial=20)
# Within each batch, up to 50 connections (forks=50)
# But the throttled task → max 3 hosts at a time
# All other tasks run at full speed (up to 50 parallel)

Basic Examples

Rate-Limit API Calls

---
- name: Update monitoring agents
  hosts: all_servers
  
  tasks:
    - name: Download agent (unlimited parallel)
      ansible.builtin.get_url:
        url: "https://monitoring.example.com/agent.tar.gz"
        dest: /tmp/agent.tar.gz

    - name: Register with monitoring API (rate-limited)
      ansible.builtin.uri:
        url: "https://api.monitoring.example.com/register"
        method: POST
        body_format: json
        body:
          hostname: "{{ inventory_hostname }}"
          ip: "{{ ansible_default_ipv4.address }}"
      throttle: 5  # API can handle max 5 concurrent requests

Rolling Service Restart

---
- name: Rolling restart of web servers
  hosts: webservers
  
  tasks:
    - name: Deploy new config (parallel is fine)
      ansible.builtin.template:
        src: nginx.conf.j2
        dest: /etc/nginx/nginx.conf

    - name: Remove from load balancer
      ansible.builtin.uri:
        url: "https://lb.example.com/api/pool/remove"
        method: POST
        body_format: json
        body:
          server: "{{ inventory_hostname }}"
      throttle: 1  # One at a time to maintain capacity

    - name: Restart nginx
      ansible.builtin.systemd:
        name: nginx
        state: restarted
      throttle: 1  # Match the LB removal

    - name: Wait for nginx to be healthy
      ansible.builtin.uri:
        url: "http://{{ inventory_hostname }}/health"
        status_code: 200
      register: health
      until: health.status == 200
      retries: 10
      delay: 5
      throttle: 1

    - name: Add back to load balancer
      ansible.builtin.uri:
        url: "https://lb.example.com/api/pool/add"
        method: POST
        body_format: json
        body:
          server: "{{ inventory_hostname }}"
      throttle: 1

Database Migration

---
- name: Run database migrations
  hosts: app_servers

  tasks:
    - name: Enable maintenance mode
      ansible.builtin.copy:
        content: "maintenance"
        dest: /var/www/app/.maintenance

    - name: Run database migration (only one server at a time!)
      ansible.builtin.command: /var/www/app/artisan migrate --force
      throttle: 1  # Only ONE server should run migrations
      run_once: false  # Each server verifies its connection
      register: migration

    - name: Disable maintenance mode
      ansible.builtin.file:
        path: /var/www/app/.maintenance
        state: absent

License Server Protection

- name: Run licensed software scan
  ansible.builtin.command: /opt/scanner/run --full
  throttle: 10  # License server allows max 10 concurrent
  async: 3600   # Run up to 1 hour
  poll: 30      # Check every 30 seconds

throttle with Blocks

---
- name: Batch operations with throttle on block
  hosts: database_replicas

  tasks:
    - name: Failover operations (one at a time)
      throttle: 1
      block:
        - name: Stop replication
          ansible.builtin.command: mysql -e "STOP REPLICA"

        - name: Apply schema change
          ansible.builtin.command: mysql < /tmp/schema-update.sql

        - name: Start replication
          ansible.builtin.command: mysql -e "START REPLICA"

        - name: Verify replication status
          ansible.builtin.command: mysql -e "SHOW REPLICA STATUS\G"
          register: replica_status
          until: "'Slave_IO_Running: Yes' in replica_status.stdout"
          retries: 30
          delay: 10
      rescue:
        - name: Alert on failure
          ansible.builtin.debug:
            msg: "CRITICAL: Replication fix needed on {{ inventory_hostname }}"

Common Mistakes

# ❌ MISTAKE 1: throttle > forks (no effect)
# If forks=5 and throttle=10, only 5 can run anyway
# throttle only REDUCES parallelism, never increases it

# ❌ MISTAKE 2: Using throttle when serial is better
# If ALL tasks in a play need limiting → use serial at play level
# throttle is for when only SOME tasks need limiting

# ❌ MISTAKE 3: throttle: 0 (invalid)
# Minimum value is 1 — use it for strict serialization

# ❌ MISTAKE 4: Forgetting throttle on related tasks
# If you throttle the "remove from LB" but not the "restart",
# restarts can still happen in parallel
- name: Remove from LB
  throttle: 1  # ✅
- name: Restart service
  # ❌ Missing throttle — restarts happen at full parallelism!
  ansible.builtin.systemd:
    name: nginx
    state: restarted
# FIX: Use a throttled block for related tasks

# ❌ MISTAKE 5: Using throttle for global rate limiting
# throttle is per-task. If you have 5 tasks each with throttle: 3,
# you can have up to 3 × 5 = 15 connections to the same resource
# For global limiting, use serial at the play level

Conclusion

Use throttle when a specific task needs rate-limiting but the rest of the play can run at full parallelism. Set throttle: 1 for operations that must be strictly sequential (database migrations, load balancer drain), throttle: N for resources with a known concurrency limit (APIs, license servers), and combine with block to throttle a group of related tasks together. It's the surgical tool for concurrency control — more precise than serial, more targeted than reducing forks.