SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Run one Ansible playbook from another

import_playbook, import_tasks and include_tasks do different jobs. See which one your site.yml needs, and prove it with ansible-playbook --list-tasks.

Three ways to run one Ansible playbook from another

Ansible gives you three ways to run one playbook from another, and they are not interchangeable. import_playbook splices a whole playbook file, plays and all, into a parent playbook. import_tasks pulls a task file into a play while the playbook is being parsed. include_tasks pulls a task file into a play while the play is running. Most people who search for this want the first one: a site.yml that names the playbooks to run, in order.

The choice between the two task mechanisms is not a matter of taste. It decides which tasks ansible-playbook --list-tasks can see, and whether --tags reaches inside the file at all. This guide builds a small lab that runs entirely on the machine you are sitting at, then uses --list-tasks and --list-tags to show the difference rather than assert it.

Set up a lab you can run without a second server

Install Ansible from your distribution packages and record what you got.

sudo apt update
sudo apt install -y ansible
ansible-playbook --version

The first line of ansible-playbook --version is the ansible-core version, and the lines under it name the config file it read and the Python interpreter that will run the modules. Write that first line down before you file a bug about behaviour that differs from this page. The import and include split described here has been stable since ansible-core 2.4, so any version a current distribution ships as of September 2026 behaves the same way.

Now make a working directory and an inventory that points at this machine.

mkdir -p ~/ansible-lab/tasks
cd ~/ansible-lab
printf 'localhost ansible_connection=local\n' > inventory.ini

ansible_connection=local makes Ansible run each task in a subprocess here instead of opening SSH (secure shell) back to itself. Leave it out and the run fails on the connection, before a single task executes. You can skip the file and pass -i 'localhost,' -c local on the command line instead, where the trailing comma is what makes Ansible read the argument as a host list rather than as a filename. How an inventory file decides which hosts a play targets covers the rest of that format, and the first-playbook walkthrough covers the parts of a play this guide assumes you already have.

site.yml: import_playbook runs whole playbooks

A playbook file is a list of plays. import_playbook is the one entry allowed in that list which is not itself a play. Write site.yml:

---
- import_playbook: provision.yml
- import_playbook: verify.yml

That is the entire feature. Ansible reads both files while it parses site.yml, and splices their plays into this one in the order you wrote them. Each imported playbook keeps its own hosts: line, so the two files can target different groups. There is no dynamic equivalent for whole playbooks, so a playbook import is always static.

Two rules follow from "read at parse time". import_playbook may only appear at the top level of a playbook, never under a play's tasks:, because a task list may not hold a play-level keyword. And the path may not depend on a value the run discovers, such as a fact or a registered result, because the file is opened before any host is contacted.

Inside a play: import_tasks against include_tasks

provision.yml is an ordinary play that uses both task mechanisms, so you can compare them in one file:

---
- name: Provision the workspace
  hosts: localhost
  gather_facts: false
  vars:
    workspace: "{{ playbook_dir }}/build"
  tasks:
    - name: Create the workspace directory
      ansible.builtin.file:
        path: "{{ workspace }}"
        state: directory
        mode: "0755"

    - ansible.builtin.import_tasks: tasks/write_files.yml

    - name: Run the checks
      ansible.builtin.include_tasks: tasks/checks.yml

tasks/write_files.yml holds two tasks, and one of them carries a tag:

---
- name: Write the marker file
  ansible.builtin.copy:
    content: "workspace ready\n"
    dest: "{{ workspace }}/marker.txt"
    mode: "0644"

- name: Write the app config
  ansible.builtin.copy:
    content: |
      [app]
      name = demo
    dest: "{{ workspace }}/app.ini"
    mode: "0644"
  tags:
    - config

tasks/checks.yml holds two more, and one of those carries a different tag:

---
- name: Read the marker file back
  ansible.builtin.command: cat {{ workspace }}/marker.txt
  register: marker
  changed_when: false

- name: Show what the marker file holds
  ansible.builtin.debug:
    var: marker.stdout
  tags:
    - report

changed_when: false stops Ansible reporting a read as a change, because the command module has no way to know that cat changed nothing.

verify.yml is the second imported playbook, and it is a separate play:

---
- name: Verify the workspace
  hosts: localhost
  gather_facts: false
  vars:
    workspace: "{{ playbook_dir }}/build"
  tasks:
    - name: Confirm the app config exists
      ansible.builtin.command: test -f {{ workspace }}/app.ini
      changed_when: false

    - name: Report the workspace path
      ansible.builtin.debug:
        msg: "Workspace verified at {{ workspace }}"

Run the whole thing.

ansible-playbook -i inventory.ini site.yml

You should see two plays run in sequence, Provision the workspace and then Verify the workspace, and a recap at the end with ok counts and no failed. If the second play fails on the test -f task, the file was never written, so read the first play's output rather than the second's. A missing file makes test exit non-zero, which is exactly what you want a verification task to do.

What does --list-tasks prove?

--list-tasks parses the playbook and prints the tasks it found. It contacts nothing and changes nothing, so it is safe to run against production files.

ansible-playbook -i inventory.ini --list-tasks site.yml

Read that output against the files you wrote. Both imported playbooks show up as numbered plays, Provision the workspace first and Verify the workspace second. That is the proof that import_playbook is resolved at parse time. Under the first play you get Create the workspace directory, then Write the marker file and Write the app config listed under their own names, even though neither of those names appears anywhere in provision.yml. The import has already been flattened into the play.

Now look for what is missing. Read the marker file back and Show what the marker file holds are not in the listing. The only trace of tasks/checks.yml is one line, Run the checks, which is the name of the include_tasks task itself. Ansible cannot list what is inside, because at parse time it has not opened that file. It holds one task whose whole job is to open it later.

What does --list-tags prove?

Same idea, one level down.

ansible-playbook -i inventory.ini --list-tags site.yml

The tag list for the first play contains config, because that tag sits on a statically imported task which is now part of the play. report is absent, because it sits inside a file nothing has read yet. This is not only a display problem:

ansible-playbook -i inventory.ini --tags report site.yml

That run does nothing useful. --tags report keeps tasks tagged report and skips everything else. The Run the checks task carries no tags, so it is skipped, so the file is never opened, so the tagged task inside it never gets a chance to match. The fix is to tag the include itself and push the tag down onto what it includes:

    - name: Run the checks
      ansible.builtin.include_tasks:
        file: tasks/checks.yml
        apply:
          tags:
            - report
      tags:
        - report

Re-run --list-tags and report still does not appear, because the contents are still invisible at parse time. Re-run --tags report and the checks now execute, because the include task itself matched the filter.

Parse time against run time, and what it costs you

Everything above comes from one difference. An import is resolved when the playbook is read, so by the time the run starts there is no import left, only tasks. An include is a task, and it does its work when the play reaches it.

Static import buys visibility. The tasks are real tasks before the run begins, so --list-tasks shows them, --tags matches them one by one, and a when: written on the import_tasks line is copied onto every imported task and evaluated separately for each one. The price is that everything must be knowable at parse time: no loop:, and no filename built from a fact.

Dynamic include buys run time freedom. The filename can come from a variable, including one an earlier task set, and loop: works, with the file read again for each item. A when: on an include_tasks line is evaluated once, so a false result skips the entire file rather than each task inside it. The price is the visibility you gave up: review tools, --list-tasks and --tags all stop at the include line.

Use import_tasks unless you need something only the dynamic form can do. A reviewer reading --list-tasks output is reading the real plan of the run, and every include in the file is a hole in that plan.

One scoping detail belongs here too. Handlers are play-scoped, so a handler notified inside provision.yml runs at the end of that play, not after verify.yml finishes. Splitting one long playbook into imported playbooks therefore changes when your restarts happen. How a template change notifies a handler covers that path in detail.

Where variable scope bites

Notice that verify.yml repeats the same vars: block as provision.yml. That is required, not sloppy. A vars: block on a play belongs to that play. An imported playbook contributes its own separate plays, so the second play starts with none of the first play's variables. Delete the vars: block from verify.yml and the run stops at the first task that references workspace, with an undefined variable error naming it.

The durable fix is group_vars/all.yml next to site.yml. Every play in every imported playbook reads those values through the inventory, so you write the value once and both plays see it. You can also pass values on the import statement itself:

- import_playbook: verify.yml
  vars:
    workspace: /home/deploy/ansible-lab/build

For a value that only exists once the run has started, use set_fact. A fact set on a host stays with that host for the rest of the run, so a later play targeting the same host can read it.

Which mechanism do you actually want?

If the thing you want to run has its own hosts: line, it is a playbook, so use import_playbook in site.yml. If it is a block of tasks you reuse inside a play, and you already know at parse time that it should run, use import_tasks. If the file to run depends on something the run discovers, or you need it once per item in a list, use include_tasks.

If the task file is growing its own variables and handlers alongside it, it wants to be a role instead, with a directory layout Ansible loads for you without any import line. The difference between a playbook and a role covers where that line sits, so this guide does not re-argue it.

The shape that works: site.yml plus per-purpose playbooks

The layout that survives contact with a real fleet is one site.yml at the top holding nothing but imports, and one playbook per purpose beneath it.

site.yml
inventory.ini
group_vars/all.yml
provision.yml
verify.yml
tasks/write_files.yml
tasks/checks.yml

site.yml is then readable in ten seconds, and each child playbook still runs on its own when you only need that one piece. Run the whole thing in check mode before you run it for real.

ansible-playbook -i inventory.ini --check --diff site.yml

Check mode reports what would change without changing it, and --diff prints the content that would be written. One caveat applies directly to this lab: command and shell tasks are skipped in check mode by default, because Ansible cannot know whether an arbitrary command is safe to run. So the two command tasks above report as skipped, and a check run here reviews your file and copy tasks rather than rehearsing the whole thing. What check mode really tests goes through the modules that report poorly under it, and running one playbook across a fleet covers the inventory side once site.yml targets more than localhost.

Then keep --list-tasks in your review habit. Run it before a restructure and after one, and the difference between those two outputs is a plain text diff of the plan. That is the fastest review of a playbook split you will get, and it is the only one that reads the file the same way Ansible does.

FAQ

Why does --tags skip the tasks inside my included file?

Because tags on tasks inside an include_tasks file do not exist until the file is read, and the file is only read if the include task itself runs. With --tags report, Ansible skips every task that does not carry report, and that includes the untagged include, so the file is never opened. Tag the include_tasks task itself, and add an apply: tags: block so the tasks inside inherit the tag as well. The problem does not arise with import_tasks, because those tasks are already part of the play before tag filtering starts.

Can I use a variable in the import_playbook path?

Only a variable that already has a value when the playbook is parsed, such as one passed with -e on the command line or defined in group_vars. Anything the run discovers is too late, because import_playbook opens the file before the first host is contacted, so a fact, a registered result, or a set_fact value cannot appear in that path. There is no dynamic include for whole playbooks, so when the file must be chosen at run time, move that choice into an include_tasks inside a play.

Why is a variable from my first playbook undefined in the second?

Because vars: on a play are scoped to that play, and an imported playbook contributes its own separate plays. The second play starts without them, and the first task that references one fails with an undefined variable error naming that variable. Move shared values into group_vars/all.yml so every play reads them from the inventory, or pass them in a vars: block on the import_playbook line. For values discovered during the run, use set_fact, since a fact set on a host stays with that host for the rest of the run.

Should I use import_tasks or include_tasks by default?

Use import_tasks. It is resolved at parse time, so its tasks appear in ansible-playbook --list-tasks, --tags matches them one at a time, and anyone reading the listing sees the plan the run will follow. Switch to include_tasks only for what it alone can do: choosing the file from a value the run produced, or running the same file once per item with loop:. Every include you add is a section of the run that --list-tasks can no longer show you.