Skip to content

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" in set_fact and recommended avoiding it. That diagnosis was wrong. Re-tested directly while fixing verify_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/None result 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.stdout vs. result.stdout_lines — regex_search wants 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_search returns None on no match, which will explode a subsequent .first or .split()[0]. | default(['NOT SET'], true) | first is the standard MajorAnsible pattern (see verify_fail2ban.yml).