Ansible's own engine is a Python program that shells out to modules and needs a
matching interpreter on the control node. go-ansible reimplements that engine as
importable Go packages instead of wrapping the Python binary โ the same wire formats,
merge orders, and rendering rules, compiled into a single static executable with no interpreter
to install and no C toolchain to cross-compile against.
The bar for every component here is compatibility with the real thing,
not a plausible reimplementation of it: vault reproduces
ansible.parsing.vault.VaultAES256's exact byte format, and template
implements the native-type rendering rule that trips up naive Jinja2 ports. All eight core
repositories below are shipped and tagged; parity is still partial โ see the
module count and the engine feature matrix further down this page for exactly what that does and
doesn't cover today, re-checked against the code rather than assumed.
71 / 71full ansible.builtin surface (modules + directives)
326modules registered (builtin + posix + community.general batches 1-5)
8CLI binaries, all shipped
6CPU architectures, race-clean
Repositories
Ansible Vault-compatible AES256 encryption
encrypt with go-ansible/vault, decrypt with the real ansible-vault, and back
Reads and writes the Ansible Vault 1.1 file format byte-for-byte: AES-256-CTR with a PBKDF2-HMAC-SHA256 derived key and an encrypt-then-MAC HMAC-SHA256 tag, hex-wrapped at 80 columns โ the exact wire format of ansible.parsing.vault.VaultAES256, not a reinterpretation of it. Decryption fails closed on a bad HMAC with a constant-time comparison, before ever touching the ciphertext.
INI/YAML inventory parsing, groups, host patterns
group_vars/host_vars merge order and ancestor precedence match ansible-inventory
Parses Ansible-compatible INI and YAML inventories โ single file or a directory of them โ into the same group/host graph Ansible builds: group_vars and host_vars merged in, ancestor groups resolved in the order that makes a child group's vars win over its parent's, and "ungrouped"/"all" computed the way Ansible computes them. Ships Ansible's own host-pattern language too: globs, numeric ranges, and colon/comma combinations with ! exclusion and & intersection.
vars
libengine
Ansible's variable precedence ladder
an order-faithful subset of ansible-core's ~22 precedence levels
Implements Ansible's variable precedence as a fixed ladder of named layers โ role defaults up through play/role/block/task vars, registered vars, role params, and -e/--extra-vars โ merged low to high so a value set higher always wins. Doesn't know about roles or plays itself; a playbook engine assigns each layer's content, this package only owns the merge order, plus a Which(key) call that answers "why did this variable win?".
Jinja2-compatible templating + Ansible's filters/tests
a bare {{ expr }} renders to its native type, not a stringified one โ Ansible's own rule
Renders Ansible-flavored Jinja2 on top of a Jinja2-compatible engine: string interpolation, control structures, and Ansible's own filter and test library (to_json/from_yaml, regex_* backed by a PCRE-compatible engine, b64encode, combine, and more) layered on Jinja2's built-ins. Implements the one rule that trips up naive ports: a value that is a single {{ expr }} with nothing else around it renders to the expression's native type โ a list, a dict, an int โ not a string.
Module execution protocol + the module library
326 module names registered โ the full ansible.builtin surface (62) plus ansible.posix (14) plus five community.general batches (250 of 577) โ run against a live go-remoteexec/transport connection
Defines Ansible's module contract in Go โ a function that takes a target Connection and already-rendered args, returns a changed/failed/msg Result โ and ships 326 modules on top of it: all 62 of ansible.builtin's real modules, all 14 of ansible.posix (synchronize is an honest, always-failing stub rather than a silent approximation โ real synchronize runs rsync from the controller directly against the target's own SSH endpoint, which the Connection abstraction can't expose from inside a module), and five curated community.general batches โ package managers (apk/homebrew/snap/flatpak/pacman/npm/yarn/pnpm/gem/bundler/composer/zypper/dnf_versionlock/macports/pkgin/pkgng/xbps/pkg5/portage/pipx and more), language/dev tooling (cpanm/cargo/golang_package/maven_artifact/pear/opkg/django), filesystem/storage (archive/decompress/ini_file/xml/read_csv/crypttab/lvg/lvol/lvm_pv/btrfs/zfs/zpool/vdo/parted/filesystem/xfs_quota and more), networking (nmcli/interfaces_file/iptables_state/ufw/nsupdate/wakeonlan/ip_netns), system/service management (sudoers/pam_limits/pamd/timezone/locale_gen/modprobe/cronvar/logrotate/puppet/monit/runit/sysrc/homectl/launchd and more), SELinux (selinux_permissive/sefcontext/selogin/seport), Pacemaker cluster management, LDAP (ldap_entry/ldap_attrs/ldap_search/ldap_passwd/ldap_inc), FreeIPA (ipa_user/ipa_group/ipa_host/ipa_vault/ipa_pwpolicy and more), Redis, Consul (kv, acl policies/roles/tokens, agent services/checks), Kerberos, desktop/system config (dconf/osx_defaults/kdeconfig/java_keystore/xfconf/gconftool2 and more), LXD/LXC containers, HashiCorp Nomad, database admin (mssql_db/vertica_*/influxdb_* via sqlcmd/vsql/influx), RHEL subscription management, AIX (aix_devices/aix_filesystem/aix_lvg and more), Elastic Stack plugins (elasticsearch/logstash/kibana), Icinga2, Kopia backup, version control (bzr/hg), web/app servers (apache2/jboss), ISO tools (xorriso-backed), and a handful of misc modules (git_config/ssh_config/htpasswd/java_cert/mail) โ deliberately excluding SaaS-API wrappers and cloud-VPS/hardware providers that need real API client SDKs rather than shell composition. Unlike real Ansible, which copies a Python script to the target and runs it there, each module here runs its logic on the control node and reaches the target only through the connection's Exec/Put/Fetch primitives.

Fact gathering โ the gather_facts/setup module equivalent
collects the whole ansible_facts/ansible_* set in one shell round trip, no Python
Gathers the standard ansible_facts/ansible_* set โ distribution, os_family, architecture, and more โ with a single portable shell probe sent over a go-remoteexec/transport connection, instead of copying and running Ansible's Python setup module.
Playbook/task/handler execution engine
17 end-to-end tests against a real Local connection, including a real become/sudo run
Runs a parsed playbook against an inventory with a real per-host linear-strategy executor: when/loop/register, block/rescue/always with genuine per-host recovery, notify/handlers, become, pre_tasks/post_tasks ordering, roles, include_role/import_role, include_tasks/import_tasks, import_playbook, vars_files, delegate_to, serial, tags/--tags/--skip-tags, meta: flush_handlers/clear_facts, add_host, group_by, and include_vars โ wiring inventory+vars+template+modules+facts together the way ansible-playbook does. Only a strategy other than linear is rejected outright; a couple of edge cases carry a documented single-host-layer limitation (nested role variable scoping, register: on setup:); see the feature matrix on this page for exactly which.
cli
clicli
All 8 Ansible CLI binaries
a real playbook run against localhost proving idempotency/loops/ignore_errors/register+changed_when, plus real ad-hoc runs in both real ansible argument orderings, plus a real interactive ansible-console session
Eight thin binaries โ ansible, ansible-playbook, ansible-vault, ansible-galaxy, ansible-pull, ansible-doc, ansible-config, ansible-console โ wiring the libraries above (plus go-git for ansible-galaxy's role installs and ansible-pull's repo fetch) into the actual commands Ansible users type. All eight support --version; ansible-playbook also wires --tags/-t and --skip-tags. ansible-doc prints each module's own Go doc comment (not real ansible-doc's structured DOCUMENTATION-YAML rendering, stated plainly); ansible-config reads real ANSIBLE_* environment variables (no ansible.cfg file support); ansible-galaxy only clones a role from a git URL, no galaxy.ansible.com API or collections. Also published as a multi-arch FROM scratch OCI image, ghcr.io/go-ansible/cli, on every version tag. All six of Go's 64-bit targets โ amd64, arm64, riscv64, loong64, ppc64le, s390x โ cross-compile clean and race-clean.
Engine feature matrix
The silent-no-op class โ a playbook key that parses without error but does
nothing โ is a worse risk than an outright missing feature, since a playbook using it succeeds
while quietly not doing what it claims. This table reflects playbook/engine.go and
playbook/playbook.go as read directly, not the shipped-package list: several of these
fields exist on the Play/Task structs and parse cleanly from YAML while
the engine never reads them.
| Playbook feature | Status | Detail |
|---|
when / loop / register | Implemented | Per-host, real conditionals and loop expansion |
block / rescue / always | Implemented | Genuine per-host recovery, not just parsed |
notify / handlers | Implemented | Deduplicated, run once per host after the play |
become | Implemented | Real sudo end-to-end test in CI |
pre_tasks / post_tasks | Implemented | Concatenated into the task list in pre โ tasks โ post order and executed normally |
roles | Implemented | Loaded from roles/<name>/{tasks,handlers,defaults,vars}/main.yml, spliced into a synthetic block task; role handlers fold into the play's handler list |
include_role / import_role | Implemented | Same synthetic-block path as roles:, resolved statically at parse time (not re-evaluated per host) |
include_tasks / import_tasks | Implemented | Referenced file's task list spliced in as a synthetic block, resolved statically at parse time |
vars_files | Implemented | Loaded and merged into Play.Vars at parse time, lower precedence than an explicit vars: key |
delegate_to | Implemented | Task runs against the delegate target's connection (templated, cached per batch); falls back to a batch host's own connection when the target is also a play host |
serial | Implemented | Splits matched hosts into batches; each batch runs every task and handler to completion before the next starts |
tags / --tags, --skip-tags | Implemented | Tag inheritance computed once at parse time; Engine.RunTags/SkipTags filter tasks (never handlers), and both CLI flags are wired on ansible-playbook |
meta: flush_handlers / clear_facts | Implemented | Both run for real; any other meta: action errors instead of silently no-op'ing |
add_host | Implemented | Adds a host (with vars and groups) to the live inventory mid-run |
group_by | Implemented | Adds the current host to an inventory group mid-run |
include_vars | Implemented | Loads a YAML file's keys into the host's vars, bare-name accessible like set_fact |
inventory_hostname / playbook_dir | Implemented | Set as magic variables after host_vars, so a same-named host_var can't shadow them |
--version (all 8 CLI binaries) | Implemented | ansible, ansible-playbook, ansible-vault, ansible-galaxy, ansible-pull, ansible-doc, ansible-config, ansible-console all print a version string and exit 0 |
strategy | Not yet | Anything other than linear (or unset) is rejected with an explicit parse error โ never silently accepted and ignored |
import_playbook | Implemented | Resolved statically at parse time like import_tasks: splices in every play from the referenced file, whose own nested imports/roles resolve relative to its own directory |
| Nested role variable scoping | Documented limit | Single-level save/restore only: a role included from inside another role's own tasks leaves its vars/defaults in place for the rest of the outer role, rather than being scoped to just the inner role |
register: on a setup:/gather_facts: task | Documented limit | The registered variable holds the facts flat, not nested under ansible_facts โ the common bare ansible_facts.X access (via automatic per-host fact injection) works fine regardless |
Also out of scope today: the namespace/collection metadata system, dynamic
inventory plugins, lookup/callback plugins, and ansible.cfg file support (though
ansible-config does read real ANSIBLE_* environment variables).
ansible-galaxy here only clones a role from a git URL โ no galaxy.ansible.com API,
no collection installs. This matrix reflects the code as read on 2026-09-03; engine work is
active in parallel, so treat it as a snapshot, not a promise.
Module coverage
modules/registry.go registers 326 module
names in total, each running against a live go-remoteexec/transport connection.
All 62 of ansible.builtin's real modules are there, plus its 9 non-module
playbook-engine directives (add_host, group_by,
import_playbook, import_role, import_tasks,
include_role, include_tasks, include_vars, meta)
wired directly into the playbook engine (see the feature matrix above) โ 71 of 71
ansible.builtin items, module and directive alike, for real. Beyond builtin: all
14 of ansible.posix (synchronize is an honest,
always-failing stub โ real synchronize runs rsync from the controller directly
against the target's own SSH endpoint, which this port's connection abstraction can't expose
from inside a module), and five curated batches of 250 of community.general's 577
modules โ package managers, language/dev tooling, filesystem/storage, networking, system/service
management, SELinux, read-only facts, Pacemaker cluster management, LDAP, FreeIPA, Redis, Consul,
Kerberos, desktop/system config, LXD/LXC containers, HashiCorp Nomad, database admin, RHEL
subscription management, AIX, Elastic Stack plugins, InfluxDB, Icinga2, Kopia backup, version
control (bzr/hg), web/app servers, ISO tools, and a handful of misc modules, deliberately
excluding SaaS-API wrappers (Slack, PagerDuty, GitLab, Jenkins, and similar) and cloud-VPS/
hardware providers (Scaleway, Linode, OpenNebula, Redfish/iLO/iDRAC, and similar) that need real
API client SDKs rather than shell composition over a connection. ~327 more community.general
modules remain โ an increasingly SaaS/cloud/hardware-vendor-skewed remainder โ plus every
cloud-provider collection (amazon.aws/azure/google.cloud and similar โ a
fundamentally different kind of work, not yet started), remain unported.
Dependencies
The low-level connection layer โ SSH, local execution, become/sudo โ lives
outside this org, in go-remoteexec/transport,
a project-neutral package shared with go-puppet-bolt/bolt.
modules, playbook, and cli depend on it directly rather than
shipping their own transport.
Benchmarks
Real, measured numbers against a real ansible-core install โ one
machine, N=10 runs, first discarded as warm-up, ansible_connection: local on both
sides. Full methodology, every command, and the caveats in
BENCHMARKS.md.
| Measure | go-ansible | real ansible-core |
|---|
| Control-node footprint | ~14M static binary (9.5M stripped) | ~132M (51M venv + 81M Python interpreter it needs) |
| 8-task playbook, median wall clock | 25ms | 1694ms (~68ร slower) |
| Process-startup cost alone (1-task no-op) | 7ms | 300ms |
| Minimal working container | FROM scratch, 21.3MB, runs and exits 0 | python:3.13-slim + pip install, 284MB โ scratch is not reachable at all |
The FROM scratch proof has a real limit, reported rather than
smoothed over: command/shell tasks fork /bin/sh, which
scratch doesn't have, so those specific modules need a shell-bearing base
(e.g. busybox:musl) even though every module that runs entirely on the controller โ
debug, set_fact, and others โ works in true scratch today.