Ansible group_by — Create Dynamic Groups at Runtime

Introduction

ansible.builtin.group_by creates host groups dynamically during playbook execution based on facts or variables. Instead of maintaining static inventory groups for every OS, datacenter, or role combination, group_by builds them automatically — then you target those groups in subsequent plays.

Basic Usage

---
- name: Organize hosts by OS
  hosts: all
  tasks:
    - name: Group by OS family
      ansible.builtin.group_by:
        key: "os_{{ ansible_os_family | lower }}"
      # Creates groups: os_debian, os_redhat, os_suse, etc.

- name: Configure Debian hosts
  hosts: os_debian
  tasks:
    - name: Configure APT
      ansible.builtin.template:
        src: sources.list.j2
        dest: /etc/apt/sources.list

- name: Configure RedHat hosts
  hosts: os_redhat
  tasks:
    - name: Configure DNF
      ansible.builtin.template:
        src: dnf.conf.j2
        dest: /etc/dnf/dnf.conf

Common Grouping Patterns

By Distribution

    - name: Group by distribution
      ansible.builtin.group_by:
        key: "distro_{{ ansible_distribution | lower | replace(' ', '_') }}"
      # Creates: distro_ubuntu, distro_centos, distro_rocky, etc.

    - name: Group by distribution version
      ansible.builtin.group_by:
        key: "distro_{{ ansible_distribution | lower }}_{{ ansible_distribution_major_version }}"
      # Creates: distro_ubuntu_22, distro_ubuntu_24, distro_rocky_9

By Architecture

    - name: Group by CPU architecture
      ansible.builtin.group_by:
        key: "arch_{{ ansible_architecture }}"
      # Creates: arch_x86_64, arch_aarch64

By Memory/Capacity

    - name: Group by memory tier
      ansible.builtin.group_by:
        key: >-
          mem_{{ 'small' if ansible_memtotal_mb < 4096
                 else 'medium' if ansible_memtotal_mb < 16384
                 else 'large' }}
      # Creates: mem_small, mem_medium, mem_large

By Network

    - name: Group by subnet
      ansible.builtin.group_by:
        key: "subnet_{{ ansible_default_ipv4.network | replace('.', '_') }}"
      # Creates: subnet_192_168_1_0, subnet_10_0_0_0

    - name: Group by datacenter
      ansible.builtin.group_by:
        key: "dc_{{ datacenter | default('unknown') }}"

By Custom Variable

    - name: Group by application role
      ansible.builtin.group_by:
        key: "role_{{ app_role | default('unassigned') }}"
      # Creates: role_web, role_db, role_cache, role_unassigned

    - name: Group by environment
      ansible.builtin.group_by:
        key: "env_{{ env | default('dev') }}"
      # Creates: env_dev, env_staging, env_production

Multi-Play Pattern

# Play 1: Gather facts and group
- name: Classify all hosts
  hosts: all
  tasks:
    - ansible.builtin.group_by:
        key: "os_{{ ansible_os_family | lower }}"
    - ansible.builtin.group_by:
        key: "{{ 'needs_reboot' if ansible_reboot_pending | default(false) else 'no_reboot' }}"

# Play 2: OS-specific packages
- name: Debian packages
  hosts: os_debian
  tasks:
    - ansible.builtin.apt:
        name: [curl, wget, jq]
        state: present

- name: RedHat packages
  hosts: os_redhat
  tasks:
    - ansible.builtin.dnf:
        name: [curl, wget, jq]
        state: present

# Play 3: Reboot hosts that need it
- name: Reboot pending hosts
  hosts: needs_reboot
  serial: 1
  tasks:
    - ansible.builtin.reboot:
        reboot_timeout: 300

group_by vs Static Groups

Featuregroup_byStatic Groups
Defined inPlaybook (runtime)Inventory file
Based onFacts, variablesManual assignment
MaintenanceAutomaticManual updates needed
AvailableAfter the group_by taskFrom start
PersistenceCurrent run onlyPermanent

Combining with add_host

- name: Provision and classify
  hosts: localhost
  tasks:
    - name: Create instances
      amazon.aws.ec2_instance:
        name: "server-{{ item }}"
        instance_type: "{{ item.type }}"
        state: running
      loop:
        - { name: web1, type: t3.micro }
        - { name: db1, type: r6g.large }
      register: instances

    - name: Register instances
      ansible.builtin.add_host:
        name: "{{ item.item.name }}"
        ansible_host: "{{ item.instances[0].public_ip_address }}"
        server_type: "{{ item.item.type }}"
      loop: "{{ instances.results }}"

- name: Classify by instance type
  hosts: all
  tasks:
    - ansible.builtin.group_by:
        key: "{{ 'compute' if 't3' in server_type | default('') else 'memory' }}"

Troubleshooting

IssueSolution
Group is emptyCheck fact/variable value; use debug to verify
Invalid group nameKey must be valid group name (alphanumeric, underscore, hyphen)
Group not availablegroup_by must run in an earlier play
Special characters in keyUse replace filter: {{ value | replace('.', '_') }}
Always reports "changed"Add changed_when: false

Best Practices

  1. Group early — run group_by in the first play
  2. Use lowercase — {{ value | lower }} for consistent group names
  3. Sanitize names — replace dots, spaces, special chars
  4. Combine groupings — multiple group_by calls create multiple groupings
  5. Default values — {{ var | default('unknown') }} prevents errors
  6. Document groups — comment what groups are created for readability

Conclusion

group_by eliminates the need to manually maintain OS-specific, region-specific, or capacity-specific inventory groups. Let Ansible discover facts and build groups automatically — then target those groups in subsequent plays. It's the foundation for truly portable playbooks that work across mixed environments without inventory changes.