Gauge Hartwell
← all write-ups

misleading abstraction

The Error Named a Python Class, Not the Bug: ansible-core 2.19's Data Tagging Rewrite

TL;DR: ansible-playbook started failing with errors naming internal Python types most people have never heard of — _AnsibleTaggedList, type-combination failures between a dict and something that isn't quite a dict. The actual trigger was a one-line YAML formatting mistake in a single file. Fixing every place that error seemed to point at first would have meant refactoring a large chunk of the codebase for the wrong reason.

The symptom

[ERROR]: unexpected parameter type in action: <class '...AnsibleTaggedList'>

and, in a different play:

failed to combine variables, expected dicts but got a 'dict' and a '_AnsibleTaggedList'

Neither error names a variable, a file, or a task — just an internal Python class that doesn't appear anywhere in this build's actual code.

Investigation

Research confirmed ansible-core 2.19 introduced a large internal rewrite — "Data Tagging" — to how variable type and origin metadata gets tracked through templating and module argument passing. Described by the wider community as the largest change to ansible-core's internals since collections were introduced, with multiple official and third-party collections still shipping compatibility fixes for it well after release. That context mattered: it meant the error text pointing at an obscure internal type wasn't a fluke, it was a symptom of a real, actively evolving compatibility gap.

The first instinct — pin back to ansible-core 2.18 — hit its own, completely unrelated wall: the original install had gone through apt/PPA, which made core dependencies like resolvelib dpkg-owned rather than pip-owned. pip refused to uninstall packages it had no installation record for, blocking the version change before it could even be tried.

Rather than resolve the packaging conflict first, the actual playbook error was traced to its specific trigger before touching anything about the version: a YAML file containing a list that mixed plain strings and dictionary mappings as sibling items — a pattern that had worked fine before, and that 2.19's new Data Tagging logic appears to handle inconsistently during variable-merge operations. On closer inspection, the specific file actually causing the immediate error turned out to have a genuine, separate formatting mistake, adjacent to but distinct from that broader mixed-type-list pattern.

Root cause

Two distinct things stacked together: a real, one-line formatting error in a single group_vars file, and a broader, still-evolving compatibility gap in ansible-core 2.19+'s handling of mixed-type variable merging that made diagnosing the first issue harder than it needed to be — because the error pointed at the symptom (an internal Python type) rather than the cause (malformed YAML).

Fix

The formatting error itself was a direct, one-line correction. The broader version question was a separate, deliberate decision rather than something to keep fighting mid-debug: stay on ansible-core 2.18.x rather than chase a still-shifting 2.19+ compatibility target for now. Three real options existed for actually managing that version going forward, given the apt/pip ownership conflict:

  1. Purge apt's copy entirely and go pip-only
  2. Manage the version strictly through apt, accepting no control over timing
  3. Isolate a pip-managed ansible-core inside a Python virtualenv, keeping the two installation methods from ever touching each other's dependencies

Went with the venv. Purging apt risked cascading removal of system packages other tools might depend on (python3-yaml, python3-jinja2 among them); managing strictly through apt meant no control over version timing when a future compatibility issue like this one shows up again. A venv isolates the pip-managed Ansible completely, so neither installation path can conflict with the other going forward.

What this demonstrates

When a new, large-scope error appears right after a tooling change, the instinct to fix everything the error text seems to be describing is worth resisting until the specific trigger is confirmed. Here, that would have meant auditing and potentially rewriting every mixed-type list across the codebase, when the real fault was a single line in one file. Diagnosing the specific trigger before choosing a general fix avoided a large, unnecessary refactor. The version-management decision alongside it is a reminder that not every resolution is a bug fix — sometimes it's picking the isolation boundary that's easiest to reverse if it doesn't work out, rather than the option that looks cleanest on paper but is harder to undo.