Ansible regex_search Capture Groups in set_fact: the Backslash Escaping Is Context-Dependent, Not Broken¶
Corrected 2026-09-30. This article originally concluded that
regex_search's capture-group argument "doesn't reliably work" inset_factand recommended avoiding it. That diagnosis was wrong. Re-tested directly while fixingverify_fail2ban.yml(see Forgejo-CI): the capture-group form works fine and is already used successfully elsewhere in the same file. The real cause — confirmed with a minimal three-way test, below — is that the backslash in'\1'needs different escaping depending on whether the surrounding YAML scalar is quoted or not, and getting that wrong fails silently (empty result) or loudly ('NoneType' object has no attribute 'group') rather than with any hint about escaping. The original incident almost certainly hit this same mismatch and mis-attributed it to the filter itself.
Problem¶
You want to extract a value from a registered command's stdout — or, as in the case that
prompted this correction, from a file read via lookup('file', ...) — using
regex_search's capture-group form inside set_fact:
- name: Capture a value
ansible.builtin.set_fact:
some_value: "{{ some_string | regex_search('pattern (.+)$', '\\1') }}"
Depending on how the task is written, this either works perfectly or fails with:
- An empty/
Noneresult with no error, or The filter plugin 'ansible.builtin.regex_search' failed: 'NoneType' object has no attribute 'group'
The exact same logical pattern, copy-pasted into a differently-formatted task, can flip between these outcomes — which is what makes it look like the filter is unreliable.
Root cause¶
The capture-group form works correctly. What changes is how many backslashes reach Jinja after YAML's own escaping rules run, and that depends entirely on the YAML scalar style the Jinja expression is written in:
| YAML scalar style | Example | What Jinja receives | Correct capture-group arg |
|---|---|---|---|
Double-quoted "..." |
"{{ x \| regex_search('(.+)', '\\1') }}" |
YAML un-escapes \\ → \, so Jinja sees '\1' |
'\\1' (double backslash in the YAML source) |
Unquoted block scalar (>- or \|) |
>-{{ x \| regex_search('(.+)', '\1') }} |
No YAML-level escaping at all — Jinja sees exactly what's typed | '\1' (single backslash in the YAML source) |
Using the double-quoted-string escaping (\\1) inside an unquoted block scalar sends
Jinja the literal two-character sequence \\1 instead of \1. Ansible's regex_search
back-reference handling does not treat \\1 as equivalent to \1 — it fails to resolve
the group and raises the NoneType has no attribute 'group' error (or, with a default()
guard chained after a filter that still errors internally, silently resolves to the
default instead of the group).
Verified with a minimal reproduction (2026-09-30)¶
- hosts: localhost
gather_facts: false
vars:
stdout_sample: "7 upgraded, 0 newly installed"
tasks:
- name: Double-quoted string, double backslash — WORKS
ansible.builtin.set_fact:
a: "{{ stdout_sample | regex_search('([0-9]+) upgraded', '\\1') }}"
- name: Unquoted block scalar, single backslash — WORKS
ansible.builtin.set_fact:
b: >-
{{ stdout_sample | regex_search('([0-9]+) upgraded', '\1') }}
- name: Unquoted block scalar, double backslash — FAILS
ansible.builtin.set_fact:
c: >-
{{ stdout_sample | regex_search('([0-9]+) upgraded', '\\1') }}
Result: a and b both resolve to ['7']; c raises
'NoneType' object has no attribute 'group'. Note also that a successful capture-group
match returns a list (['7']), not a bare string — chain | first before further
string operations.
Fix¶
Match the backslash count to the scalar style you're actually using:
- Writing the Jinja expression as a double-quoted YAML string (
"{{ ... }}")? Use\\1. - Writing it as an unquoted block scalar (
>-or|)? Use\1. - When editing an existing task, check which style it already uses before copying a capture-group snippet from elsewhere in the same file or from documentation — the two styles are not interchangeable and the error gives no hint that escaping is the issue.
# Double-quoted string
some_value: "{{ some_string | regex_search('pattern (.+)$', '\\1') | first }}"
# Unquoted block scalar
some_value: >-
{{ some_string | regex_search('pattern (.+)$', '\1') | first }}
If you'd still rather sidestep the issue entirely, the original article's workaround
remains valid: match the broader substring with regex_search('pattern') (no
back-reference argument at all), then peel the value out with plain string operations:
pkg_count: "{{ (apt_upgrade_result.stdout | regex_search('[0-9]+ upgraded') | default('0')).split()[0] }}"
This avoids the back-reference argument entirely, so the escaping question never comes
up — at the cost of an extra .split()/slice step.
Where this came up in MajorAnsible¶
verify_fail2ban.yml's existing found_sender/found_destemail/found_mta/
found_action tasks (double-quoted strings, double-backslash form) have worked
correctly all along — proof the capture-group form itself is fine. A new task added
2026-09-30, written as an unquoted >- block scalar but copy-pasted with the
double-quoted string's double-backslash escaping, failed identically on every host with
and without --check until the backslash count was corrected to match the block-scalar
style. Full incident: Forgejo-CI § verify_fail2ban.yml real assertion failure.
See also ansible-lookup-file-relative-path-playbook-dir for a second, unrelated gotcha hit in the same fix.
Quick checks if this still misbehaves¶
- Confirm the source variable is a string, not a list.
result.stdoutvs.result.stdout_lines—regex_searchwants a string. - Confirm which YAML scalar style you're in before trusting a backslash count copied from elsewhere — this is the check that resolves the overwhelming majority of "capture group doesn't work" reports.
- Always provide a default.
regex_searchreturnsNoneon no match, which will explode a subsequent.firstor.split()[0].| default(['NOT SET'], true) | firstis the standard MajorAnsible pattern (seeverify_fail2ban.yml).
Related¶
- ansible-lookup-file-relative-path-playbook-dir — a second Jinja/path gotcha found in the same session
- ansible-vault-password-file-missing — another set_fact / vault interaction quirk
- ansible-ssh-timeout-dnf-upgrade — companion gotcha when running
update.yml - Forgejo-CI — the project file tracking the
verify_fail2ban.ymlfix that prompted this correction