Skip to main content
RunBook Academy

AnsibleXXXIII · Delegation and Controller-Side ExecutionDelegation and controller-side execution

delegate_facts: whose facts are these?

Advanced⏱ ~24 minansible-core

What you'll learn

  • Predict where a fact set during a delegated task is stored, and prove it
  • Use delegate_facts to gather facts about a host that is not in the play
  • Recognise fact corruption caused by a missing delegate_facts and diagnose it
  • Decide between delegate_facts, a fact-gathering play, and not branching on the facts at all

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

A task that produces a fact and a task that produces a change are treated very differently by this course, and delegation is where the distinction earns its keep.

A delegated change is loud: something happened on another machine, and if it was the wrong machine you find out. A delegated fact is silent. It lands in the run’s variable store under some host’s name, and nothing checks that the name matches the machine the measurement came from. A run that misfiles every fact it gathers reports ok on every task.

The rule is one sentence, and the reason it surprises people is that it is the opposite of what “delegate” suggests.

The default

Facts produced during a delegated task are stored against inventory_hostname — the original host — not against the host the task ran on.

Demonstrated, with set_fact standing in for anything that produces a fact, on two app servers delegating to lb1:

Read-only / Safewhere a delegated fact lands
$ ansible-playbook -i deleg.ini delegfacts.yml
TASK [Set a fact while delegated, WITHOUT delegate_facts] **********************
ok: [web1 -> lb1]
ok: [web2 -> lb1]

TASK [Where did it land - original host] ***************************************
ok: [web1] => {
  "msg": "on web1: probe_source=gathered-while-delegated"
}
ok: [web2] => {
  "msg": "on web2: probe_source=gathered-while-delegated"
}

TASK [Where did it land - lb1] *************************************************
ok: [web1] => {
  "msg": "hostvars[lb1].probe_source=UNSET"
}

The task ran on lb1. The fact is on web1 and web2. lb1 has nothing.

Now the same shape with delegate_facts: true:

Read-only / Safedelegate_facts sends it to the delegate instead
$ ansible-playbook -i deleg.ini delegfacts.yml
TASK [Set a fact while delegated, WITH delegate_facts] *************************
ok: [web1 -> lb1]
ok: [web2 -> lb1]

TASK [Check both again] ********************************************************
ok: [web1] => {
  "msg": "self.probe2=UNSET | lb1.probe2=landed-on-delegate"
}
ok: [web2] => {
  "msg": "self.probe2=UNSET | lb1.probe2=landed-on-delegate"
}

delegate_facts: true moves the destination. It does not duplicate it. After that task, web1 does not have probe2 at all — a detail that matters if a later task on web1 was written expecting it.

The keyword documentation says the same thing in one line:

Read-only / Safethe keyword, from the installed core
$ ansible-doc -t keyword delegate_facts
delegate_facts:
applies_to:
- Role
- Block
- Task
- Handler
description: Boolean that allows you to apply facts to a delegated host instead
  of inventory_hostname.
priority: 0
template: explicit
type: bool

Note applies_to: it is not a play-level keyword. You set it on the task, block, role or handler that is doing the delegating.

Why the default is the right one

It looks like a trap, and it is one — but reversing it would be worse.

Most delegated tasks exist to learn something about the current host from somewhere else. Query the monitoring API for this host’s alert state. Ask the load balancer whether this host is still receiving traffic. Look up this host’s record in the CMDB. In every one of those, the answer belongs to the app server even though the question was asked from elsewhere:

- name: Ask the balancer whether this host is still serving
  ansible.builtin.uri:
    url: "https://lb.example.com/api/backends/{{ inventory_hostname }}"
    headers:
      Authorization: "Bearer {{ lb_api_token }}"
  register: pool_state
  delegate_to: "{{ app_loadbalancer }}"
  no_log: true

pool_state is web1’s pool state. Storing it against lb1 would be wrong, and would also mean every host in the play overwrote the previous one’s answer.

The default serves the common case. delegate_facts: true is for the uncommon one, and the uncommon one is worth naming precisely: you want facts about the delegate itself.

Gathering facts about a host that is not in the play

This is the legitimate use, and it is genuinely useful.

Your play targets app servers. You want to make a decision based on the load balancer’s operating system, its available memory, or which service_mgr it runs — and the load balancer is not in the play, so nothing has ever gathered facts for it.

Read-only / Safegather facts for a host outside the play
- name: Gather facts about the load balancer
ansible.builtin.setup:
  gather_subset:
    - '!all'
    - '!min'
    - service_mgr
    - distribution
delegate_to: "{{ app_loadbalancer }}"
delegate_facts: true
run_once: true

- name: Now this is answerable
ansible.builtin.debug:
  msg: >-
    Balancer {{ app_loadbalancer }} runs
    {{ hostvars[app_loadbalancer]['ansible_distribution'] }}
    under {{ hostvars[app_loadbalancer]['ansible_service_mgr'] }}

Three things in that task are deliberate:

  • delegate_facts: true puts the facts on the balancer, where they belong and where hostvars[...] can find them.
  • gather_subset restricts collection to the two subsets actually needed. Full gathering against a delegate is a real cost paid once per host in the play — see the next paragraph — and there is no reason to collect network interfaces and mount tables to answer a question about the service manager.
  • run_once: true because otherwise this gathers the balancer’s facts once for every host in the play. Fifty app servers means fifty setup runs against one proxy, which is a self-inflicted load spike on the machine you are trying to be careful with. run_once has semantics of its own that lesson 5 covers in detail; here it is doing the obvious job.

What the failure actually looks like

The break/fix for this lesson is a real shape, and it is instructive because the first diagnosis is always wrong.

A team writes a play over web that gathers facts from the balancer to decide how to reload it, and forgets delegate_facts. The symptom reported is: “the playbook thinks our load balancer is Ubuntu, but it is Rocky Linux.”

The obvious diagnoses — a wrong inventory entry, a stale fact cache, an ansible_host pointing at the wrong machine — are all false. Nothing is misconfigured. The facts describing the balancer were gathered correctly from the balancer, and then filed under web1, web2, web3, silently overwriting each app server’s real facts with the proxy’s.

The diagnostic is direct, once you suspect it:

Read-only / Safecompare what a host claims against what it is
# What does the run believe about web1?
ansible -i inventory/production web1 -m ansible.builtin.setup \
-a 'gather_subset=!all,!min,distribution' 2>&1 | grep -E 'distribution|hostname'

# What does the run believe about the balancer?
ansible -i inventory/production lb1 -m ansible.builtin.setup \
-a 'gather_subset=!all,!min,distribution' 2>&1 | grep -E 'distribution|hostname'

If a direct setup against web1 disagrees with what the playbook reported for web1, something in the play wrote facts into web1 from elsewhere. Search the repository for delegate_to on tasks that produce facts: setup, set_fact, and any module used with register where the result is later promoted to a fact.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A play targets 30 app servers. One task runs ansible.builtin.setup delegated to lb1, with no delegate_facts. Where do the resulting facts end up?

  2. Q2. You add delegate_facts: true to that task. What changes for the app servers?

  3. Q3. Which of these are true about register and facts under delegation? Select all that apply.

  4. Q4. Setting delegate_facts: true on a task that has no delegate_to raises an error, so the mistake is caught at run time.

Passing score: 75%. Answers are checked in this browser.