Ansible

Ansible check if software package is installed on Linux

A dpkg-query -l check exits 0 for a package that is not installed, as long as dpkg has seen that package before. So the playbook most people copy to check if a package is installed with Ansible reports nano as present on a Debian host where nano was removed. The reliable check is the package_facts module, which reads the package database and gives you a dictionary of installed packages to test with a plain in.

Original content from computingforgeeks.com - post 74987

An earlier version of this page used the dpkg-query -l pattern, and a reader rightly called out the false positive. We rewrote every playbook: package_facts first, then a command check with dpkg-query -W and rpm -q, the package module in check mode, and a playbook that installs only what is missing. Each playbook and output below came from ansible-core 2.21 driving a Debian 13 host and a Rocky Linux 10 host in November 2026.

Set up the inventory

Every playbook here targets a group called servers, so you need an inventory with your Debian and Red Hat family hosts in it. If Ansible isn’t on your control node yet, follow the Ansible install guide for Rocky Linux and Ubuntu first. Create the inventory file:

vim hosts

Add your hosts. Ours are called debian13 and rocky10, so you replace those names with your own hostnames or IP addresses.

[servers]
debian13
rocky10

[servers:vars]
ansible_user=root
ansible_python_interpreter=auto_silent

Setting the interpreter to auto_silent keeps Python discovery automatic but drops the interpreter warning that ansible-core prints for every host on every run, which would otherwise bury the results you care about. If you connect as a regular user instead of root, add ansible_become=true under [servers:vars] so the install tasks run through sudo. Groups, host variables and per-environment inventories are covered in the Ansible inventory management guide.

Check if a package is installed with package_facts

The package_facts module asks the host’s package manager (apt, dnf, rpm, zypper, pacman and others) for the installed package list and stores it in ansible_facts.packages, keyed by package name. Because the key exists only for installed packages, the test 'nano' in ansible_facts.packages is true or false and nothing in between, on every distribution family. You don’t need separate Debian and Red Hat tasks for the check itself.

Create the playbook:

vim check_packages.yml

Paste in the following. The first task runs only on Debian family hosts, because package_facts reads dpkg through the python3-apt library and fails without it.

---
- name: Check if packages are installed
  hosts: servers
  vars:
    package_names:
      - tar
      - wget
      - nano
      - telnet
      - tree
  tasks:
    - name: Install python3-apt so package_facts can read dpkg
      ansible.builtin.apt:
        name: python3-apt
        state: present
      when: ansible_facts['os_family'] == "Debian"

    - name: Gather the package inventory
      ansible.builtin.package_facts:

    - name: Report each package
      ansible.builtin.debug:
        msg: "{{ item }} is {{ 'installed' if item in ansible_facts.packages else 'NOT installed' }}"
      loop: "{{ package_names }}"

The rpm backend has the same kind of dependency on the RPM Python bindings. Rocky Linux already has them because python3-dnf depends on them, but Fedora 41 and later and SUSE don’t install them by default, so add a similar task installing python3-rpm if you manage those.

We picked five packages so the Debian host covers the common states dpkg reports. On the Rocky host only tar is present, and on the Debian host you have:

  • tar: installed (ii)
  • wget: installed and held with apt-mark hold (hi)
  • nano: removed with apt remove, config files kept (rc)
  • telnet: installed once, then removed (un)
  • tree: never installed, unknown to dpkg

Run the playbook against both hosts.

ansible-playbook -i hosts check_packages.yml

Debian reports tar and the held wget as installed and everything else as missing, including the removed nano that the old dpkg-query -l check counted as present. Rocky reports only tar:

PLAY [Check if packages are installed] *****************************************

TASK [Gathering Facts] *********************************************************
ok: [debian13]
ok: [rocky10]

TASK [Install python3-apt so package_facts can read dpkg] **********************
skipping: [rocky10]
ok: [debian13]

TASK [Gather the package inventory] ********************************************
ok: [rocky10]
ok: [debian13]

TASK [Report each package] *****************************************************
ok: [debian13] => (item=tar) => {
    "msg": "tar is installed"
}
ok: [rocky10] => (item=tar) => {
    "msg": "tar is installed"
}
ok: [debian13] => (item=wget) => {
    "msg": "wget is installed"
}
ok: [rocky10] => (item=wget) => {
    "msg": "wget is NOT installed"
}
ok: [rocky10] => (item=nano) => {
    "msg": "nano is NOT installed"
}
ok: [debian13] => (item=nano) => {
    "msg": "nano is NOT installed"
}
ok: [debian13] => (item=telnet) => {
    "msg": "telnet is NOT installed"
}
ok: [rocky10] => (item=telnet) => {
    "msg": "telnet is NOT installed"
}
ok: [debian13] => (item=tree) => {
    "msg": "tree is NOT installed"
}
ok: [rocky10] => (item=tree) => {
    "msg": "tree is NOT installed"
}

PLAY RECAP *********************************************************************
debian13                   : ok=4    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
rocky10                    : ok=3    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0   

The python3-apt task reports ok even on a host that didn’t have the library. The apt module installs python3-apt for itself before anything else, so the library is already there when it checks the package. Our Debian host’s apt history log shows the install landing during that task. Inside your own tasks, use ansible_facts.packages['tar'][0].version when you need the installed version as well as a yes or no.

Error: Could not detect a supported package manager

Run package_facts against a host that lacks its backend’s Python library (python3-apt on Debian and Ubuntu, python3-rpm on Fedora 41 and later or SUSE) and the task fails with Could not detect a supported package manager from the following list: ['apk', 'apt', 'pacman', 'pkg', 'pkg_info', 'portage', 'rpm', 'dnf', 'dnf5', 'yum', 'zypper', 'pkg5', 'pkgng', 'openbsd_pkg'], or the required Python library is not installed. Minimal and container images are the usual suspects. The message lists apt as supported, which sends people hunting for a missing apt binary, but the module lacks the Python library, not the tool. We hit it on Debian, where keeping the ansible.builtin.apt task from the playbook above in front of package_facts fixed it. On rpm hosts, install python3-rpm once and the same error goes away.

Why dpkg-query -l gives a false positive

dpkg-query -l lists every package dpkg has a record for, and it exits 0 whenever it finds a record, whatever that record says. A package removed with apt remove keeps a record in state rc (removed, config files remain), and a package that was installed and later dropped can sit there as un (unknown, not installed). The first letter is the desired action and the second is the actual status, so a held package that is installed shows hi. Run it on the Debian host against the removed nano:

dpkg-query -l nano; echo "exit code: $?"

The first two letters are rc, so nano is not installed, yet the exit code is 0 and Ansible marks the task as succeeded.

Desired=Unknown/Install/Remove/Purge/Hold
| Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend
|/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad)
||/ Name           Version       Architecture Description
+++-==============-=============-============-============================================
rc  nano           8.4-1+deb13u1 arm64        small, friendly text editor inspired by Pico
exit code: 0

We checked each state on the Debian host. Only a name dpkg has never seen produces a non-zero exit code, so the exit code tells you whether dpkg knows the name, not whether the package is installed.

Package statedpkg statusdpkg-query -l exit code
Installedii0
Installed and heldhi0
Removed, config files keptrc0
Known to dpkg, not installedun0
Never seen by dpkgnone1

The old playbook had three more problems, and we reproduced each one before rewriting it.

  • The Debian and Red Hat tasks both used register: package_check, and a skipped task still registers its skip result, so on Debian the Red Hat task overwrote the real result and package_check is succeeded tested a skip
  • A name dpkg has never seen made the command task fail and stopped the play for that host, so the install-if-missing branch never ran
  • The package module version in check mode reported succeeded whether or not the package was there, because a would-install result is still a success

Each fix is small on its own: a distinct register name per task, failed_when: false on the query, and a test on changed rather than succeeded. The next two sections apply them.

Query dpkg and rpm directly

Sometimes you want the package manager’s own answer, for example on a host where you cannot add python3-apt. A command check still works if it reads the status instead of trusting the exit code. On Debian, dpkg-query -W -f='${db:Status-Status}' prints the actual package status as a word, and installed covers held packages as well. Don’t test ${db:Status-Abbrev} for ii: its three characters are the desired action, the status and an error flag, so a held package reads hi and fails that test. On Red Hat family hosts rpm -q is already honest, since it exits 0 only for an installed package. It also matches the package name exactly, so rpm -q vim reports not installed on Rocky Linux, where the package is vim-enhanced.

Create the playbook:

vim check_packages_cmd.yml

Each query gets its own register name, changed_when: false stops a read-only query from showing as a change, and failed_when: false keeps a missing package from killing the play.

---
- name: Check packages with dpkg-query and rpm
  hosts: servers
  vars:
    package_names:
      - tar
      - wget
      - nano
      - telnet
      - tree
  tasks:
    - name: Query dpkg on Debian family
      ansible.builtin.command: dpkg-query -W -f='${db:Status-Status}' {{ item }}
      loop: "{{ package_names }}"
      register: dpkg_check
      changed_when: false
      failed_when: false
      when: ansible_facts['os_family'] == "Debian"

    - name: Query rpm on Red Hat family
      ansible.builtin.command: rpm -q {{ item }}
      loop: "{{ package_names }}"
      register: rpm_check
      changed_when: false
      failed_when: false
      when: ansible_facts['os_family'] == "RedHat"

    - name: Report Debian results
      ansible.builtin.debug:
        msg: "{{ item.item }} is {{ 'installed' if item.stdout == 'installed' else 'NOT installed' }}"
      loop: "{{ dpkg_check.results }}"
      loop_control:
        label: "{{ item.item }}"
      when: ansible_facts['os_family'] == "Debian"

    - name: Report Red Hat results
      ansible.builtin.debug:
        msg: "{{ item.item }} is {{ 'installed' if item.rc == 0 else 'NOT installed' }}"
      loop: "{{ rpm_check.results }}"
      loop_control:
        label: "{{ item.item }}"
      when: ansible_facts['os_family'] == "RedHat"

Run it against the Debian host, since that’s where the old check went wrong.

ansible-playbook -i hosts check_packages_cmd.yml --limit debian13

The held wget counts as installed, the removed nano and the un record for telnet both come back as not installed, and the unknown tree no longer stops the play:

PLAY [Check packages with dpkg-query and rpm] **********************************

TASK [Gathering Facts] *********************************************************
ok: [debian13]

TASK [Query dpkg on Debian family] *********************************************
ok: [debian13] => (item=tar)
ok: [debian13] => (item=wget)
ok: [debian13] => (item=nano)
ok: [debian13] => (item=telnet)
ok: [debian13] => (item=tree)

TASK [Query rpm on Red Hat family] *********************************************
skipping: [debian13] => (item=tar) 
skipping: [debian13] => (item=wget) 
skipping: [debian13] => (item=nano) 
skipping: [debian13] => (item=telnet) 
skipping: [debian13] => (item=tree) 
skipping: [debian13]

TASK [Report Debian results] ***************************************************
ok: [debian13] => (item=tar) => {
    "msg": "tar is installed"
}
ok: [debian13] => (item=wget) => {
    "msg": "wget is installed"
}
ok: [debian13] => (item=nano) => {
    "msg": "nano is NOT installed"
}
ok: [debian13] => (item=telnet) => {
    "msg": "telnet is NOT installed"
}
ok: [debian13] => (item=tree) => {
    "msg": "tree is NOT installed"
}

TASK [Report Red Hat results] **************************************************
skipping: [debian13] => (item=tar) 
skipping: [debian13] => (item=wget) 
skipping: [debian13] => (item=nano) 
skipping: [debian13] => (item=telnet) 
skipping: [debian13] => (item=tree) 
skipping: [debian13]

PLAY RECAP *********************************************************************
debian13                   : ok=3    changed=0    unreachable=0    failed=0    skipped=2    rescued=0    ignored=0   

The same playbook against rocky10 reports tar installed and the other four missing, matching package_facts. You get the same answer with twice the YAML, so we reach for it only when the facts module can’t run. If you branch on os_family like this a lot, the list of Ansible os_family and distribution facts shows the value each distribution reports.

Use check mode with the package module

Running ansible.builtin.package with state: present in check mode asks the package manager what it would do without doing it. A missing package comes back changed, because the module would install it, and an installed one comes back ok. So the test is is changed, never is succeeded. On Debian hosts the apt module needs python3-apt for check mode and will not install it in that mode. Instead it fails with python3-apt must be installed to use check mode, so you run the package_facts playbook once first or install the library yourself.

vim check_mode_packages.yml

Add this playbook, which checks tar, the held wget and the missing nano:

---
- name: Check packages with the package module in check mode
  hosts: servers
  vars:
    package_names:
      - tar
      - wget
      - nano
  tasks:
    - name: Ask the package manager what it would install
      ansible.builtin.package:
        name: "{{ item }}"
        state: present
      check_mode: true
      loop: "{{ package_names }}"
      register: pkg_check

    - name: Report each package
      ansible.builtin.debug:
        msg: "{{ item.item }} is {{ 'NOT installed' if item is changed else 'installed' }}"
      loop: "{{ pkg_check.results }}"
      loop_control:
        label: "{{ item.item }}"

Run the check.

ansible-playbook -i hosts check_mode_packages.yml

The held wget comes back ok on Debian, so it counts as installed, while Rocky would have to install both wget and nano. Nothing was installed on either host, because check mode never touches the system.

PLAY [Check packages with the package module in check mode] ********************

TASK [Gathering Facts] *********************************************************
ok: [debian13]
ok: [rocky10]

TASK [Ask the package manager what it would install] ***************************
ok: [rocky10] => (item=tar)
ok: [debian13] => (item=tar)
changed: [rocky10] => (item=wget)
ok: [debian13] => (item=wget)
changed: [rocky10] => (item=nano)
changed: [debian13] => (item=nano)

TASK [Report each package] *****************************************************
ok: [debian13] => (item=tar) => {
    "msg": "tar is installed"
}
ok: [rocky10] => (item=tar) => {
    "msg": "tar is installed"
}
ok: [debian13] => (item=wget) => {
    "msg": "wget is installed"
}
ok: [rocky10] => (item=wget) => {
    "msg": "wget is NOT installed"
}
ok: [debian13] => (item=nano) => {
    "msg": "nano is NOT installed"
}
ok: [rocky10] => (item=nano) => {
    "msg": "nano is NOT installed"
}

PLAY RECAP *********************************************************************
debian13                   : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
rocky10                    : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

Check mode is slower than package_facts because it calls the package manager once per package. It earns its place when you also want to know that the package is actually installable from the configured repositories, which package_facts can’t tell you.

Install only the missing packages

Most of the time you check so you can install whatever is missing. Filter the list against ansible_facts.packages with reject('in', ...), print what’s missing so the run log shows it, and hand the whole list to one package task so the package manager resolves it in a single transaction. The Ansible conditionals and loops tutorial goes deeper on when and filters like this one.

vim install_missing.yml

Add the playbook below. It reuses the python3-apt task from earlier.

---
- name: Install packages that are missing
  hosts: servers
  vars:
    package_names:
      - tar
      - nano
      - tree
  tasks:
    - name: Install python3-apt so package_facts can read dpkg
      ansible.builtin.apt:
        name: python3-apt
        state: present
      when: ansible_facts['os_family'] == "Debian"

    - name: Gather the package inventory
      ansible.builtin.package_facts:

    - name: Work out which packages are missing
      ansible.builtin.set_fact:
        missing_packages: "{{ package_names | reject('in', ansible_facts.packages) | list }}"

    - name: Show the missing packages
      ansible.builtin.debug:
        var: missing_packages

    - name: Install the missing packages
      ansible.builtin.package:
        name: "{{ missing_packages }}"
        state: present
      when: missing_packages | length > 0

Then run it.

ansible-playbook -i hosts install_missing.yml

Both hosts were missing nano and tree, and each installed them in one change.

PLAY [Install packages that are missing] ***************************************

TASK [Gathering Facts] *********************************************************
ok: [debian13]
ok: [rocky10]

TASK [Install python3-apt so package_facts can read dpkg] **********************
skipping: [rocky10]
ok: [debian13]

TASK [Gather the package inventory] ********************************************
ok: [rocky10]
ok: [debian13]

TASK [Work out which packages are missing] *************************************
ok: [debian13]
ok: [rocky10]

TASK [Show the missing packages] ***********************************************
ok: [debian13] => {
    "missing_packages": [
        "nano",
        "tree"
    ]
}
ok: [rocky10] => {
    "missing_packages": [
        "nano",
        "tree"
    ]
}

TASK [Install the missing packages] ********************************************
changed: [debian13]
changed: [rocky10]

PLAY RECAP *********************************************************************
debian13                   : ok=6    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
rocky10                    : ok=5    changed=1    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0   

A second run prints an empty missing_packages list on both hosts and skips the install task, with changed=0 in the recap. On its own, state: present already skips installed packages, so if all you want is “install if missing”, a single package task does the job without any check. The package_facts step pays off when you report, branch, or skip work based on what is installed, such as running a migration only on hosts that still have an old package. More patterns like this are collected in the Ansible cheat sheet, and the Ansible variables tutorial covers set_fact and registered variables in detail.

Keep reading

OpenCode CLI Cheat Sheet: Commands, Sessions and Fixes (PDF) AI OpenCode CLI Cheat Sheet: Commands, Sessions and Fixes (PDF) Best Mini PC for Home Assistant: 4 Picks for HAOS and Proxmox Automation Best Mini PC for Home Assistant: 4 Picks for HAOS and Proxmox Ansible + Proxmox: Automated VM Management Ansible Ansible + Proxmox: Automated VM Management Best Weather Stations for Home Assistant: Ecowitt, Tempest, Davis Automation Best Weather Stations for Home Assistant: Ecowitt, Tempest, Davis Best Water Leak Sensors and Auto Shutoff Valves for Home Assistant Automation Best Water Leak Sensors and Auto Shutoff Valves for Home Assistant Configure Jenkins behind Nginx and Let’s Encrypt SSL Jenkins Configure Jenkins behind Nginx and Let’s Encrypt SSL

2 thoughts on “Ansible check if software package is installed on Linux”

  1. Quite sloppy solution.

    $ dpkg-query -l “{{ item }}”

    Will return true (0) when the package was previously removed, but still appears on the output of dpkg / dpkg-query with the “n” (Not) installed status.

    Because of this, the ansible conditional: “package_check is succeeded” will give you a big nice false positive.

    Reply
    • You’re right: dpkg-query -l exits 0 for rc and un packages too, and the skipped RedHat task was overwriting the register as well. The guide now uses package_facts ('nano' in ansible_facts.packages), and the command fallback checks dpkg-query -W -f='${db:Status-Status}' for installed, so held packages count too. Both are tested on Debian 13 and Rocky Linux 10.

      Reply

Leave a Comment

Press ESC to close