Skip to content
All posts

Custom Ansible Modules in Python: Practical Guide for Network Engineers

If you have used Ansible for any length of time, you have hit the wall where the built-in modules almost solve your problem but not quite. Maybe you need to parse a Cisco IOS show running-config for hostname, serial number, and privileged users in one pass. Maybe you have a vendor REST API that returns nested JSON no uri task can clean up. Maybe you want a single, idempotent task that wraps three CLI calls so your playbook is not a Jinja jungle.

That is exactly what custom Ansible modules in Python are for. A custom module is a small Python script that uses the AnsibleModule class to accept arguments from a playbook, do work on the target (or the control node), and return JSON back. When it is in the right directory or packaged in a collection, you call it from a play just like ansible.builtin.copy or any other shipped module.

This guide walks through every part of writing a production-quality custom Ansible module in 2026: the directory layout Ansible actually scans, the AnsibleModule skeleton you cannot skip, idempotency and check-mode patterns, how to test and debug locally, the errors that break new modules every time, and how to package the result into a collection that works inside AAP, AWX, and Execution Environments.

What we will cover:

Ready to automate your network but don't know where to start? Moving from manual network management to Infrastructure as Code feels overwhelming when you're starting from zero. CloudMyLab provides pre-built Ansible playbooks, real-world use case templates, and integrated testing environments that connect directly to EVE-NG or Cisco CML for immediate validation. Get enterprise-grade NetDevOps tools without the learning curve. Contact us to explore automation environments that teach while you build.

 

Custom Ansible Modules at a Glance

Before you write any Python, it helps to see how a custom module compares to the other ways Ansible lets you extend itself. A lot of “I need a custom module” problems are actually filter-plugin or role problems in disguise.

Extension type What it is Runs on Use when
Custom module Standalone Python script using AnsibleModule Target node (or delegate_to: localhost) You need a new task verb with idempotent behavior
Action plugin Python class that runs on the controller before/after a module Control node You need controller-side logic that wraps a module
Filter plugin Python function exposed to Jinja2 Control node (template eval) You only need to transform variables
Lookup plugin Python function that returns data into a variable Control node You need to read external data into a play
Role YAML + tasks + templates bundled together Wherever the tasks run You are composing existing modules, not writing new logic
Collection Distributable bundle of modules, plugins, roles Whatever they target You want to share or version several of the above

A custom module is the right answer when you need a new verb in a playbook, with arguments, return values, and idempotent behavior. If you only need to massage a string, write a filter plugin. If you only need to pull data into a variable, write a lookup plugin. If you only need to chain three existing modules with some logic, write a role.

If you are running Ansible in a managed lab environment because production is too risky to test custom Python against, CloudMyLab’s hosted Ansible labs give you a safe-to-fail environment with the platform already wired up. Team plans land in the $2K–$5K initial / $45K 3-year TCO range, well below the $50K–$100K of a comparable on-premise lab build. You are not paying for Ansible itself. You are paying to skip the week of Execution Environment and inventory plumbing that usually comes with it.

What is an Ansible Custom Module?

An Ansible custom module is a Python script that imports the AnsibleModule helper class, parses a known set of arguments, performs an action, and returns a JSON document Ansible can read. To Ansible, your custom script is indistinguishable from a built-in module like ansible.builtin.file or cisco.ios.ios_command. To Python, it is a regular script with a main() function and an if __name__ == '__main__': main() guard.

Two things make a module different from a normal Python script:

  1. It uses AnsibleModule(argument_spec=...) to declare the inputs the playbook can pass in.
  2. It returns results by calling module.exit_json(...) on success or module.fail_json(...) on failure, never with print() or return.

Everything else (regex parsing, REST calls, file I/O, network connections) is standard Python. There is no special framework to learn. The discipline is in the input and output contract, not the logic in between.

Custom modules are particularly useful in network automation when you need to slice complex show output, transform vendor-specific data structures, validate configurations against a baseline, or wrap multi-step CLI workflows so playbooks stay readable. For background on the broader landscape of Ansible module types and how they fit together, see Ansible Modules Types Explained.

When You Should Write a Custom Module (and When You Shouldn’t)

Most engineers write a custom module for one of three honest reasons: a built-in module does 80% of what they need, the remaining 20% requires real Python, or the team has a recurring task they want to express as a single playbook verb. The wrong reasons are equally common. Writing a module to glue together tasks you could put in a role, or to do data transformation a Jinja filter would handle, leaves you with code to maintain without a real benefit.

A custom module is the right tool when you need:

  • A new task that is idempotent, supports check mode, and returns structured data
  • Logic that is too complex for set_fact and Jinja templates without becoming unreadable
  • Wrapping a vendor SDK or REST API in a way that survives ansible-lint and ansible-test
  • Parsing device output (Cisco IOS, JUNOS, NX-OS, Arista EOS) that no parse_cli_textfsm template captures cleanly

A custom module is the wrong tool when:

  • A filter plugin or lookup plugin would do the same job in fewer lines
  • You can compose ansible.builtin.uri, ansible.builtin.set_fact, and a Jinja template
  • The logic is one-shot and does not need to be reusable
  • You actually want a role, because the work is composing existing modules in a specific order

If you are not sure, write the playbook first using built-in modules. If the playbook ends up with three nested set_fact calls, a loop over register results, and a filter you have to apologize for, that is the moment a module saves you. For broader playbook structure guidance, see Best Practices for Writing Efficient Ansible Playbooks.

Ansible Module Directory Structure: Where to Place a Custom Module

Ansible looks for custom modules in a few predictable places. The simplest layout, and the one you should reach for first, is a library/ directory next to your playbook. Anything inside that directory becomes available to plays in the same project without extra configuration.

your_project/
├── ansible.cfg
├── inventory.ini
├── playbook.yml
└── library/
    └── command_parser.py

When command_parser.py lives in library/, you call it from playbook.yml as command_parser: with whatever arguments it expects. No collections: line, no import_role, no plumbing.

If you have several playbooks that share the same modules, set a global library path in ansible.cfg:

[defaults]
library = ~/ansible_modules:./library
inventory = ./inventory.ini

You can also point Ansible at a directory using the ANSIBLE_LIBRARY environment variable. Both approaches work, but they are second-best to a collection once you have more than two or three modules. Collections version cleanly, install through ansible-galaxy, and slot directly into Execution Environments. The library/ directory is fine for prototyping; it is not where production code should live long-term.

A few rules that catch new module authors every time:

  • The filename becomes the module name. command_parser.py is called command_parser: in the playbook.
  • Module filenames must use underscores, not hyphens. Python cannot import command-parser.
  • The file does not need to be executable on most systems, but chmod +x does not hurt.
  • Ansible scans library/ automatically. Subdirectories inside library/ are not scanned the same way; use a collection if you want nesting.

Anatomy of a Custom Ansible Module: The AnsibleModule Skeleton

Every custom module follows the same shape. Get the skeleton right and the rest is just whatever Python you need in the middle.

#!/usr/bin/env python3
from ansible.module_utils.basic import AnsibleModule


def run_module():
    # 1. Declare the arguments the playbook can pass in
    module_args = dict(
        running_config=dict(type='list', required=True, elements='str'),
        match_users=dict(type='bool', required=False, default=True),
    )

    # 2. Build the AnsibleModule instance
    module = AnsibleModule(
        argument_spec=module_args,
        supports_check_mode=True,
    )

    # 3. Default result dict
    result = dict(
        changed=False,
        output={},
    )

    # 4. Short-circuit if the user passed --check
    if module.check_mode:
        module.exit_json(**result)

    # 5. Real work goes here. Wrap it in try/except so failures
    #    return JSON, not Python tracebacks.
    try:
        config_lines = module.params['running_config']
        result['output'] = {'line_count': len(config_lines)}
    except Exception as exc:
        module.fail_json(msg='Module failed', exception=str(exc))

    # 6. Return JSON
    module.exit_json(**result)


def main():
    run_module()


if __name__ == '__main__':
    main()

Six pieces matter here:

argument_spec is the contract between your playbook and your code. Each key declares an argument name, its type (str, list, bool, int, dict, path), whether it is required, and an optional default. elements='str' tells Ansible the list contains strings, which is required for newer versions and prevents type warnings.

AnsibleModule(argument_spec=..., supports_check_mode=True) is the helper object. It parses arguments, validates types, handles --check and --diff, and gives you module.params, module.exit_json(), and module.fail_json().

module.params is a dictionary of the parsed arguments. Always read inputs from module.params['name'], never from sys.argv.

module.check_mode is True when the user runs the play with --check. Custom modules that change state must return changed=True if they would have changed something, and must not actually make the change.

module.exit_json(**result) succeeds and returns the result dict to Ansible. The dict can include any keys; changed is the only required one.

module.fail_json(msg=...) fails the task. Always pass msg. Optionally include exception= for tracebacks, but never let an unhandled Python exception escape the module — it produces a wall of traceback in the playbook output and breaks structured error handling.

This skeleton is the baseline for every example below.

Full Example: A Cisco IOS Config Parser Module

Here is a complete, working custom module that takes a Cisco IOS show running-config (as a list of lines passed in from a previous task), and returns the hostname, domain name, product ID, serial number, and configured usernames. It is the kind of one-pass parsing job built-in modules cannot do cleanly and Jinja filters cannot do at all.

The playbook that calls it:

---
- name: IOS command parser
  hosts: r3
  gather_facts: false
  tasks:
    - name: Capture running-config
      cisco.ios.ios_command:
        commands: show running-config
      register: sh_command

    - name: Parse the running-config
      command_parser:
        running_config: ""
      register: parser_output

    - name: Show parsed values
      ansible.builtin.debug:
        msg: ""

The module file at library/command_parser.py:

#!/usr/bin/env python3
from ansible.module_utils.basic import AnsibleModule
import re


def parse_running_config(lines):
    """Pull structured data out of a Cisco IOS running-config."""
    output = "\n".join(lines)
    parsed = {}

    hostname_pattern = re.compile(r'^hostname (\S+)', re.MULTILINE)
    domain_pattern = re.compile(r'^ip domain[- ]name (\S+)', re.MULTILINE)
    pid_pattern = re.compile(r'license udi pid (\S+) sn (\S+)')
    username_pattern = re.compile(r'^username (\S+) privilege (\d{1,2})', re.MULTILINE)

    if (match := hostname_pattern.search(output)):
        parsed['hostname'] = match.group(1)

    if (match := domain_pattern.search(output)):
        parsed['domain'] = match.group(1)

    if (match := pid_pattern.search(output)):
        parsed['product_id'] = match.group(1)
        parsed['serial_number'] = match.group(2)

    parsed['users'] = [
        {'name': m.group(1), 'privilege': int(m.group(2))}
        for m in username_pattern.finditer(output)
    ]

    return parsed


def run_module():
    module_args = dict(
        running_config=dict(type='list', required=True, elements='str'),
    )

    module = AnsibleModule(
        argument_spec=module_args,
        supports_check_mode=True,
    )

    result = dict(changed=False, output={})

    if module.check_mode:
        module.exit_json(**result)

    try:
        result['output'] = parse_running_config(module.params['running_config'])
    except Exception as exc:
        module.fail_json(msg='Failed to parse running-config', exception=str(exc))

    module.exit_json(**result)


def main():
    run_module()


if __name__ == '__main__':
    main()

A snippet of the config the module will see:

hostname R3
ip domain name lab.local
license udi pid ISR4431/K9 sn FGL2103A123
username admin privilege 15 secret 5 $1$abc$xyz
username guest privilege 1 secret 5 $1$def$xyz

The structured output Ansible returns to the playbook:

{
  "changed": false,
  "output": {
    "hostname": "R3",
    "domain": "lab.local",
    "product_id": "ISR4431/K9",
    "serial_number": "FGL2103A123",
    "users": [
      {"name": "admin", "privilege": 15},
      {"name": "guest", "privilege": 1}
    ]
  }
}

A few things this module gets right that the average first attempt misses. Parsing logic is in its own function, which means you can unit test parse_running_config directly without bringing up Ansible. Regular expressions use re.MULTILINE so anchors actually anchor at line boundaries. The output schema is consistent — users is always a list of dicts, never a list of strings, never None, never a single dict when there is one user. Consumers of the module never have to write defensive Jinja for missing keys.

Idempotency, Check Mode, and Returning Data Cleanly

A module that works on the first run and then keeps reporting changed: true on every subsequent run is broken, even if the playbook does the right thing. Three patterns separate a module that just runs from a module that behaves like a built-in.

Idempotency. If the module changes state, it has to check the current state first and only act when the desired state differs. A module that always writes a config file should read the file first, compare it to the desired content, and only write when they differ. Set result['changed'] = True only on real change.

def ensure_banner(module, desired_banner):
    current = read_current_banner(module)
    if current == desired_banner:
        return False  # no change
    if not module.check_mode:
        write_banner(module, desired_banner)
    return True  # changed

Check mode. A module that supports check mode tells the user what would change without actually changing anything. Pass supports_check_mode=True to AnsibleModule, and inside the module branch on module.check_mode. The pattern above does this implicitly — when module.check_mode is true, the function still reports the would-be changed state without performing the write.

Diff mode. If your module changes text-shaped state (config files, banners, ACLs), return a diff dict so users running --diff see exactly what changed:

diff = {
    'before': current_config,
    'after': desired_config,
}
module.exit_json(changed=True, diff=diff, msg='Banner updated')

Returning data cleanly. The output of exit_json becomes the register-ed result variable in the playbook. Keep keys stable, types consistent, and None out of the schema. Never return a Python object that does not serialize to JSON. If you want to expose data to subsequent tasks as facts, return them under the ansible_facts key:

module.exit_json(
    changed=False,
    ansible_facts={'parsed_config': parsed},
)

After the task runs, parsed_config is available like any other fact in the play.

A useful sanity check: anything you would write register: x and then reach for in x.something.deeply.nested is a sign the schema is wrong. Flatten the output, or break the module in two.

How to Test and Debug a Custom Ansible Module

Test custom modules locally before you wire them into a playbook that touches real devices. There are three ways that work, in increasing fidelity to how Ansible actually runs the module.

Direct Python execution (fastest, lowest fidelity). Pull the parsing function out (as we did with parse_running_config above) and call it from a pytest test or a small driver script. This catches logic bugs without involving Ansible at all and is the right place to put the bulk of your tests.

# tests/test_command_parser.py
from library.command_parser import parse_running_config

def test_parses_hostname():
    lines = ['hostname R3', 'ip domain name lab.local']
    result = parse_running_config(lines)
    assert result['hostname'] == 'R3'
    assert result['domain'] == 'lab.local'

Ansible ad-hoc command (medium fidelity). Run the module the way Ansible runs it, with arguments parsed through AnsibleModule, but without writing a full playbook:

ansible localhost -m command_parser \
  -a "running_config=['hostname R3','ip domain name lab.local']"

This catches mistakes in argument_spec, JSON return shape, and check-mode handling. It does not catch playbook-level integration issues.

Playbook with check mode (highest fidelity). Run the actual playbook with --check --diff -vvv:

ansible-playbook playbook.yml --check --diff -vvv

The -vvv shows the exact JSON arguments Ansible passed to the module and the exact JSON the module returned. If something is wrong, the answer is in this output.

For deeper debugging inside the module, use module.debug() and module.warn() instead of print():

module.debug('Parsed users: %s' % parsed['users'])
module.warn('No PID line found in config')

module.debug() output is visible at -v verbosity. module.warn() shows up regardless. Both are safe; print() is not — it pollutes the JSON Ansible expects on stdout and can break the task entirely.

For module-level testing the Ansible community uses, see ansible-test (ansible-test sanity, ansible-test units, ansible-test integration). It is the same harness used to test built-in modules, and it is the right tool once your module lives in a collection.

Troubleshooting Common Custom Module Errors

Almost every “it doesn’t work” question about a custom module traces back to one of five issues. Here is what to check, in order.

ERROR! couldn't resolve module/action 'command_parser' means Ansible could not find your module file. Confirm the file is in library/ next to the playbook (or in a path declared via library = in ansible.cfg), the filename matches the module name in the play exactly, the filename uses underscores not hyphens, and there is no typo in either place.

ImportError: No module named 'requests' (or any other library) means the Python environment running the module is missing a dependency. The fix depends on where the module runs. For modules that run on the control node (most network modules using network_cli, anything with delegate_to: localhost), install the library on the control node with pip. For modules that run on managed hosts, install the library on the host or use a different module. Inside Execution Environments for AAP or AWX, declare the dependency in the EE’s requirements.txt. See Ansible Execution Environment for the EE workflow end to end.

AttributeError: 'bool' object has no attribute 'get' is almost always a wrong type in argument_spec colliding with the data the playbook is actually passing. If the module declares running_config=dict(type='list') but the playbook passes a string, you get a confusing error inside your parsing code. Print module.params early in the module, or run with -vvv to see the actual argument values, and tighten the type spec.

Module Failure with no detail usually means an unhandled exception in the module body. The fix is the try/except block in the skeleton — wrap the module body, and call module.fail_json(msg=..., exception=str(exc)) so the playbook output gets a real error instead of “module failure”. A bare exception that escapes the module is the worst possible failure mode.

SyntaxError: invalid syntax in a custom module on a control node still running an older Python. Modern Ansible (ansible-core 2.14+) requires Python 3.10 or newer on the control node. If your module uses walrus operators, structural pattern matching, or modern type hints, make sure the control node Python supports them. For Python on managed network devices, see the discussion of ansible_python_interpreter in Ansible Setup Step by Step.

If none of the above match, run with -vvv. The verbose output shows the JSON Ansible sent to the module and the JSON the module sent back. Most “weird” errors are obvious once you can see the actual data on the wire.

Packaging Custom Modules into a Collection for AAP and AWX

A library/ directory is fine for one project. Once you have a few related modules, or you want to ship them through Ansible Automation Platform (AAP) or AWX, you need a collection. A collection is a versioned bundle of modules, plugins, roles, and metadata that installs through ansible-galaxy collection install and slots into Execution Environments cleanly.

The minimum collection layout looks like this:

my_namespace/
└── network_tools/
    ├── galaxy.yml
    ├── README.md
    ├── plugins/
    │   ├── modules/
    │   │   ├── command_parser.py
    │   │   └── interface_status.py
    │   └── module_utils/
    │       └── parsers.py
    ├── roles/
    └── meta/
        └── runtime.yml

galaxy.yml is the collection manifest. It declares the namespace, name, version, dependencies, and tags:

namespace: my_namespace
name: network_tools
version: 1.0.0
readme: README.md
authors:
  - Your Team <team@example.com>
description: Custom modules for parsing Cisco IOS configurations.
tags:
  - networking
  - cisco
  - parsing
dependencies: {}

Build the collection from the project root:

ansible-galaxy collection build

You get a .tar.gz file. Install it locally to test:

ansible-galaxy collection install my_namespace-network_tools-1.0.0.tar.gz

In a playbook, reference modules by their fully qualified collection name:

- hosts: r3
  tasks:
    - name: Parse running-config
      my_namespace.network_tools.command_parser:
        running_config: ""

For AAP and AWX, the workflow is the same, with one extra step. Add the collection to your project’s requirements.yml:

collections:
  - name: my_namespace.network_tools
    version: ">=1.0.0"
    source: https://galaxy.ansible.com   # or your private Automation Hub

When AAP or AWX syncs the project (or rebuilds the Execution Environment), the collection is installed automatically. If your modules need extra Python packages, add them to the EE definition’s requirements.txt, not to the collection itself. Mixing those concerns is the most common reason a module that works locally fails in AAP. For the broader AAP picture, including how the controller, mesh, and EEs fit together, see Ansible Automation Platform Enterprise IT.

When to choose a collection over a standalone module:

Standalone module in library/ Collection
One project, one team Multiple projects or teams
1–2 modules 3+ modules, or modules + filters + roles
No external distribution Galaxy or private Automation Hub
AAP not in the picture AAP, AWX, or custom EEs
Quick prototyping Production code, versioned releases

For everything you can do with collections that you cannot do with a flat library, see the Ansible Galaxy overview.

Module Development Best Practices

A custom Ansible module ages well or it does not. The modules that survive five years in production share a small set of habits.

Keep the parsing logic in plain Python functions, not inside run_module. The AnsibleModule skeleton becomes a thin shell around testable functions. You can unit test the functions with pytest and never touch Ansible.

Make the output schema boring. Every field always present, every type stable, every list a list (even when empty). Consumers of the module write Jinja, and Jinja is not the place for defensive code.

Support check mode. Every module that changes state should accept supports_check_mode=True and behave correctly under --check. This is the difference between a module operators trust and a module they wrap in block: rescue: because they are scared of it.

Fail loudly through module.fail_json, not silently through print() or unhandled exceptions. The error message is what the next engineer sees at 2 a.m.

Document the module with a DOCUMENTATION string at the top of the file. ansible-doc -t module command_parser then shows real documentation, and ansible-test sanity checks it. The Ansible developer guide on documenting modules is short and worth reading once.

Use ansible-test early. Run ansible-test sanity and ansible-test units against your collection before publishing anything. The same harness that tests ansible.builtin modules tests yours.

Pin dependencies in the EE, not in the module. Modules should declare the libraries they need, but the actual installation belongs in the Execution Environment’s requirements.txt. This makes the module portable across EEs and avoids the “works on my laptop” problem.

For the bigger automation picture — credentials, vaults, secrets — see Ansible Credentials and Ansible Vault. For tooling that makes the development loop faster, Ansible Navigator gives you a TUI on top of the same EE-based workflow that AAP uses in production.

What’s Next? Build Your Own Module

The hardest part of writing your first custom Ansible module is picking the right problem. Pick something you already have to do every week:

  • A module that parses show version output for OS version, uptime, and hardware serial across Cisco, Juniper, and Arista
  • A module that validates an interface configuration against a baseline and reports drift in the same JSON shape across vendors
  • A module that wraps a vendor REST API call you currently make with ansible.builtin.uri plus three set_fact tasks
  • A module that runs ACL compliance checks against a per-site policy file

Start in library/, get one module working end to end, then graduate to a collection when you have two or three. The skeleton is the same. Only the deployment changes.

If you would rather skip the lab plumbing entirely and just get to writing modules, CloudMyLab’s hosted Ansible labs give you a managed environment with ansible-core, AAP, AWX, and Execution Environments wired up. You bring the modules; the lab is already there. Start a free trial or book a demo with one of our engineers if you want to see it before you commit.

For broader Ansible context, Ansible Setup Step by Step walks through the install on a fresh control node, Best Practices for Ansible Playbooks covers the surrounding playbook discipline, and Python Libraries for Network Automation lists the libraries most network-focused custom modules end up depending on.

Frequently Asked Questions

What’s the difference between Ansible modules and plugins?

Modules are the units of work Ansible runs on managed nodes (or the control node, for some tasks). They have an argument_spec, return JSON, and show up as task verbs in a playbook. Plugins extend Ansible’s behavior on the control node — filter plugins transform variables in Jinja, lookup plugins read external data into variables, action plugins wrap modules with controller-side logic, and inventory plugins build inventories. If you need a new task verb, write a module. If you need to transform data inside a template or before a task runs, write a plugin.

How do I call a built-in Ansible module from inside a custom module?

You do not, directly. Modules are designed to be standalone. If you need functionality similar to a built-in module, use the same Python library the built-in uses (for example, use shutil instead of trying to call ansible.builtin.copy from Python). If you need to wrap a module with controller-side logic, write an action plugin instead of trying to call modules from Python.

When should I create a *_info or *_facts module instead of extending an existing module?

Create a *_info module when your job is to gather data and return it for the playbook to register and use immediately. Create a *_facts module when the data should automatically be merged into ansible_facts and made available to every subsequent task. The convention in modern Ansible is to prefer *_info modules and let users explicitly set_fact if they want fact-like behavior; *_facts is reserved mostly for legacy compatibility.

Can I use print() statements in custom modules for debugging?

No. print() writes to stdout, which is exactly where Ansible expects the module’s JSON return value to be. Mixing the two breaks the task with a JSON parsing error. Use module.debug('message') for verbose-only output and module.warn('message') for warnings that always show. Both are safe and integrate with -v verbosity.

Can custom modules work with both Python 2.7 and Python 3?

Modern Ansible (ansible-core 2.14 and newer) requires Python 3.10 or newer on the control node, and Python 3.6 or newer on managed nodes. New custom modules should be Python 3 only. Python 2.7 compatibility is no longer a requirement and pretending it is just adds dead code paths. If you have a managed node still running Python 2.7, the right fix is to fix the managed node, not the module.

How do I define and process arguments in a custom Ansible module?

Declare arguments in the argument_spec dictionary passed to AnsibleModule. Each key is an argument name, and the value is a dict with type, required, default, and (for lists) elements. Inside the module, read values from module.params['arg_name']. Ansible validates types, applies defaults, and enforces required-ness before your code runs. If the playbook passes an invalid argument, the task fails before run_module is called.

How do I share custom modules across all my playbooks?

For two or three modules, set library = /path/to/your/modules in ansible.cfg or the ANSIBLE_LIBRARY environment variable. For anything larger, package the modules into a collection and install with ansible-galaxy collection install. Collections version cleanly, work inside Execution Environments, and are the only sensible distribution model for AAP and AWX.

What is the correct way to handle check mode in a custom Ansible module?

Pass supports_check_mode=True to AnsibleModule. Inside the module, branch on module.check_mode before any state-changing operation. If a change would happen, return changed=True with a descriptive msg, but do not actually make the change. The pattern in the Idempotency section above is the canonical shape — read current state, compare to desired state, only write outside check mode, return whether change occurred.