AnsibleXXXIII · Delegation and Controller-Side ExecutionDelegation and controller-side execution
delegate_facts: whose facts are these?
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
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:
$ ansible-playbook -i deleg.ini delegfacts.ymlTASK [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:
$ ansible-playbook -i deleg.ini delegfacts.ymlTASK [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:
$ ansible-doc -t keyword delegate_factsdelegate_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: boolNote 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.
- 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: trueputs the facts on the balancer, where they belong and wherehostvars[...]can find them.gather_subsetrestricts 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: truebecause otherwise this gathers the balancer’s facts once for every host in the play. Fifty app servers means fiftysetupruns against one proxy, which is a self-inflicted load spike on the machine you are trying to be careful with.run_oncehas 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:
# 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
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?
Q2. You add delegate_facts: true to that task. What changes for the app servers?
Q3. Which of these are true about register and facts under delegation? Select all that apply.
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.