Learn Ansible - Modularization with Includes & Imports
Episode 11 of 31

Learn Ansible - Modularization with Includes & Imports

Break giant playbooks into well-organized small files with include_tasks, import_tasks, and import_playbook, and understand when to choose dynamic include over static import.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

After episode 10, where we covered building resilient playbooks with error handling, block, rescue, and always, in this episode we'll address a problem you'll certainly face once playbooks start growing: how to manage their complexity.

Remember your experience when your application first grew large? One source file that used to be a hundred lines became three thousand lines, and everyone was afraid to touch it because one small change could break everything. Ansible playbooks go through the same fate. A playbook with all tasks written in one file is easy at first, but once it holds dozens of tasks for provisioning, package installation, service configuration, and application deployment all at once, the file becomes hard to read, hard to test, and hard to reuse in other playbooks.

In the real working world, the principle of reusability is key. Professional infrastructure teams don't rewrite the same tasks over and over; they break them into blocks that can be reused across many playbooks. In this episode, we'll cover the concept of modularization, the fundamental difference between dynamic include and static import with a comparison table, examples of applying import_tasks, include_tasks, and import_playbook, and the common mistakes that often trap people. Later in episode 12, this concept will become the foundation for understanding Ansible Roles — the industry standard for packaging automation.

Main Discussion

The Reusability Concept: Breaking Up Large Playbooks

Imagine a monolithic playbook containing 200 tasks that handle everything: from package installation, user creation, firewall configuration, to application deployment. There are three major problems here:

  1. Hard to read — reading 200 sequential tasks makes us lose the big picture.
  2. Hard to test — if one section fails, it's difficult to find the root cause because everything is mixed together.
  3. Not reusable — a firewall configuration you've already written can't be reused in another playbook without copy-paste.

The same principle as DRY (Don't Repeat Yourself) in programming applies here: each logic block is written once, then used from many places. Modularization also supports separation of concerns: task-files for install, task-files for configure, and task-files for deploy — each can be tested and maintained independently.

Here's an example of a directory structure for an already-modularized project. Notice how each responsibility is separated into a clear file:

plaintext
ansible-project/
├── ansible.cfg
├── inventory/
│   └── production.yml
├── group_vars/
│   └── all.yml
├── playbooks/
│   ├── site.yml            # main entry point playbook
│   ├── webservers.yml      # playbook specifically for web servers
│   └── database.yml        # playbook specifically for databases
└── tasks/
    ├── common.yml          # task-file: base setup for all hosts
    ├── webserver.yml       # task-file: install & configure nginx
    ├── database.yml        # task-file: install & configure postgres
    └── cleanup.yml         # task-file: artifact cleanup

With this structure, the main playbook becomes concise and intent-revealing: from reading it alone, you immediately understand that site.yml orchestrates common setup, web servers, and databases in sequence. The small files inside tasks/ hold the implementation details.

Note

An important term to distinguish from the start: what we're modularizing in this episode are the task list (list of tasks) and playbook (list of plays). A task list is pulled into a play using import_tasks / include_tasks, while a playbook is pulled into another playbook using import_playbook. Later in episode 12, you'll learn to package all three at once with Roles.

Dynamic Include vs Static Import

Ansible provides two ways to reuse files: dynamic (include_*) and static (import_*). The fundamental difference lies in when the file is processed:

  • Dynamic include is processed at runtime, when playbook execution reaches that include task. The file is read and run at that moment.
  • Static import is processed during initial parsing of the playbook, before any task executes. Tasks from the imported file are as if inserted directly into the playbook.

This difference in processing time spawns a series of very important behavioral consequences. Let's compare them fully:

Aspectinclude_tasks (Dynamic)import_tasks (Static)
Processing timeAt runtime, when the include executesAt playbook parsing, before execution
Task options (tags, when, become)Only apply to the include task itselfAutomatically applied to all tasks inside the file
LoopingCan be looped (task runs per item)Not allowed — error You cannot use loops on import_tasks
Variables in file nameAllowed: include_tasks: "{{ ansible_os_family }}.yml"Cannot use inventory/runtime variables in file name
when on the statementEvaluated once for the entire fileCopied to every task inside the file
--list-tasks / --list-tagsTasks and tags inside the file don't appearAll tasks and tags are visible
--start-at-taskCannot start from a task inside the includeCan start from an imported task
Handler (notify)Must notify the include itself by nameCan notify individual tasks inside the file

Warning

One myth to clear up: there is no include_playbook in Ansible. Playbooks can only be pulled in statically using import_playbook. Unlike task lists, which have a dynamic version (include_tasks), playbooks have no runtime equivalent — if you've ever seen code calling include_playbook, that's not an official Ansible module and will produce an error.

When to Choose Static Import?

Use import_tasks (static) as the default when:

  • The task file name is fixed (doesn't depend on variables/facts).
  • You want --list-tasks and --list-tags to display the file's entire contents, so operations (e.g., running with --start-at-task or the --tags filter) work reliably.
  • You want tags placed on the statement to be automatically inherited by all tasks inside the file.
  • You want the when condition evaluated independently per task inside the file.

The main advantage of static is predictability: since everything is processed at the start, behavior related to tags, handlers, and listing becomes more transparent.

When to Choose Dynamic Include?

Use include_tasks (dynamic) when:

  • The file name is determined at runtime, e.g., based on OS facts: include_tasks: "setup-{{ ansible_facts['os_family'] | lower }}.yml".
  • You need to loop over the include, so tasks inside the file run once per item.
  • You want when evaluated once for the entire file (not per task), because there are tasks inside the file that might change the variables used by that condition.
  • You want flexibility: the included file can be selected based on results from previous tasks (e.g., set by set_fact).

Practice: Using import_tasks

The most common pattern is splitting a large playbook into several task files, then joining them back together via import_tasks in the main playbook. For example, let's modularize a web server setup:

playbooks/site.yml
- name: Setup semua server
  hosts: all
  become: true
  tasks:
    - name: Import setup dasar semua host
      ansible.builtin.import_tasks: tasks/common.yml
 
    - name: Import setup web server
      ansible.builtin.import_tasks: tasks/webserver.yml
      when: inventory_hostname in groups['webservers']

Notice the when line on the second import: because import_tasks is static, that condition is copied to every task inside webserver.yml. As long as the file's contents are indeed meant to run only on web server hosts, this behavior is safe and actually gives every task the same condition protection. The contents of tasks/webserver.yml can be as simple as:

tasks/webserver.yml
- name: Install nginx
  ansible.builtin.apt:
    name: nginx
    state: present
    update_cache: true
 
- name: Copy konfigurasi nginx
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
 
- name: Start nginx
  ansible.builtin.systemd_service:
    name: nginx
    state: started

Tip

Because import_tasks is static, the tasks inside the file are immediately visible when you run ansible-playbook playbooks/site.yml --list-tasks. This is very helpful for verification before a real run — for example, ensuring the task order is correct before running in production.

Practice: Using include_tasks with a Loop

Now let's look at why include_tasks is needed. The classic case is selecting a task file based on OS, or running a group of tasks per item in a loop.

First example — selecting a file based on OS facts. Because the file name uses runtime variables, we must use include_tasks:

os-specific-include.yml
- name: Setup package manager sesuai OS
  hosts: all
  become: true
  tasks:
    - name: Jalankan task khusus family OS
      ansible.builtin.include_tasks: "tasks/install-{{ ansible_facts['os_family'] | lower }}.yml"

If the os_family fact is Debian, Ansible will look for and run tasks/install-debian.yml; if RedHat, it runs tasks/install-redhat.yml. You just provide both files, and the same playbook works across the entire fleet environment. This is one of the strongest patterns of dynamic include.

Second example — looping over include_tasks. The tasks inside the file run once for every item, and we can use loop_control.loop_var to give a more descriptive variable name:

include-tasks-loop.yml
- name: Konfigurasi beberapa service sekaligus
  hosts: all
  become: true
  tasks:
    - name: Konfigurasi tiap service
      ansible.builtin.include_tasks: tasks/configure_service.yml
      loop:
        - api
        - web
        - worker
      loop_control:
        loop_var: service_name

Important

Inside tasks/configure_service.yml, the default item variable won't be available — what's available is service_name, matching the loop_var we set. If you use item in that file, the playbook will fail with an undefined variable error. This is one of the most common mistakes when combining include_tasks with a loop.

The contents of tasks/configure_service.yml, for example:

tasks/configure_service.yml
- name: Buat direktori konfigurasi {{ service_name }}
  ansible.builtin.file:
    path: "/etc/{{ service_name }}"
    state: directory
 
- name: Deploy konfigurasi {{ service_name }}
  ansible.builtin.template:
    src: "{{ service_name }}.conf.j2"
    dest: "/etc/{{ service_name }}/{{ service_name }}.conf"
 
- name: Restart service {{ service_name }}
  ansible.builtin.systemd_service:
    name: "{{ service_name }}"
    state: restarted

Handling Tags on Dynamic Include

One trap that often confuses people: tags on include_tasks are not automatically inherited by the tasks inside the file — unlike import_tasks. To make tags reach the tasks inside the file, use the apply parameter:

include-tasks-tags.yml
- name: Include task dengan tags yang benar
  ansible.builtin.include_tasks:
    file: tasks/deploy.yml
    apply:
      tags: deploy
  tags: deploy

With apply, both deploy tags (on the wrapper and on the tasks inside the file) work: running ansible-playbook site.yml --tags deploy will load the include and execute the tasks inside it. Without apply, only the include wrapper passes the tags filter, while the tasks inside the file stay skipped.

Practice: Using import_playbook

Up to here we've discussed modularization within a single play. What if you want to break down at the play level — combining several playbooks into one entry point, like a site.yml that runs webservers.yml then database.yml? For that, use import_playbook, which can only be written at the top level of a playbook (not inside tasks):

playbooks/site.yml
- name: Impor playbook setup dasar
  ansible.builtin.import_playbook: common.yml
 
- name: Impor playbook web server
  ansible.builtin.import_playbook: webservers.yml
 
- name: Impor playbook database
  ansible.builtin.import_playbook: database.yml

When site.yml runs, all three playbooks above are processed and executed sequentially as one run. This is a very common orchestration pattern in infrastructure teams: one entry point for the entire deployment, with each component's details stored in separate files.

Warning

Recall the warning at the start: import_playbook is the only way to load a playbook and is static. You cannot write import_playbook inside the tasks section of a play — that will produce an error like "import_playbook ... can only be used at the top level". If you truly need dynamic playbook selection based on variables, a valid solution is to select at the inventory/--extra-vars level, or leverage a conditional on each import_playbook (with the consequence that the condition is evaluated at parsing time).

Common Pitfalls

1. Looping on import_tasks

Writing loop on import_tasks will stop the playbook with an error You cannot use loops on import_tasks (or a similar static-import message). Because the import is static, there's nothing to loop over — switch to include_tasks when you need a loop:

error-loop-import.yml
- name: Contoh yang SALAH
  ansible.builtin.import_tasks: tasks/setup.yml
  loop: "{{ services }}"
# ERROR! You cannot use loops on 'import_tasks' tasks

2. Mistakenly assuming tags on include_tasks are inherited

This is the most common source of hidden bugs. With import_tasks, tags flow to all tasks inside the file. With include_tasks, tags only touch the include wrapper — unless you use apply as in the example above. If --tags doesn't trigger the tasks you expect, first check whether you're using a dynamic include.

3. Using item inside a file included with a loop

When include_tasks is looped, the default item variable outside isn't available inside the file. Use loop_var to define an explicit variable name, and make sure the file uses that variable name.

4. Thinking import_playbook can be used inside tasks

import_playbook is only valid at the top level of a playbook. If an error appears when placing it inside tasks, that's not an Ansible bug — it's simply the rule. Move it to the top level, or consider splitting into separate plays.

5. Mixing include and import without reason

Ansible's official documentation recommends consistently choosing one approach within a playbook. Mixing static and dynamic in one playbook can surface bugs that are hard to trace, especially because of the differing behavior of when, vars, and tags between the two. As a starting point: use import_tasks for fixed paths and stable flows, and include_tasks only when you genuinely need runtime flexibility.

6. when on import_tasks is evaluated per task

A static consequence that often surprises: if you write import_tasks: foo.yml with when: some_condition, and the first task inside foo.yml changes some_condition's value (e.g., via set_fact), then the subsequent tasks in that file can end up skipped too. If the condition must be evaluated once for the whole file, use include_tasks.

Conclusion

In episode 11, we covered how to break large playbooks into well-organized small files, from the reusability concept and modular directory structure, to the fundamental difference between dynamic include (include_tasks) and static import (import_tasks) — including processing time, tags behavior, loops, variables, and --list-tasks, all summarized in the comparison table. We also saw the practice of using import_playbook to combine several playbooks into one entry point, as well as common mistakes like looping on a static import and the misunderstanding about include_playbook, which never existed.

With these modularization capabilities, your playbooks become far easier to read, test, and reuse. However, splitting per-file is only the first step toward standardization. In the industry, task list modules like tasks/common.yml shared across projects are more often packaged in a standard structure called Roles.

In episode 12, we'll cover Introduction and Explanation of Ansible Roles — how to package complete automation with task, handler, vars, defaults, templates, and files in one standardized, ready-to-reuse package for any project. Keep your enthusiasm up!

Learn Ansible - Modularization with Includes & Imports | Learn Ansible