Python tools often need to answer a deceptively simple question: is this imported object a package or an ordinary module?

That distinction matters to plugin loaders, documentation generators, test discovery systems, command-line frameworks, code indexers, and developer tools. A package can contain importable children. An ordinary module cannot be traversed in the same way.

Python 3.14 adds inspect.ispackage(), a small predicate that gives this question a standard-library name.

import inspect
import json
import pathlib

print(inspect.ispackage(json))     # True
print(inspect.ispackage(pathlib))  # False

The API is tiny. The surrounding import semantics are not.

Using inspect.ispackage() correctly requires understanding what Python means by a package, why __file__ is the wrong signal, how namespace packages fit in, and why detecting a package does not make recursively importing it safe.

A package is a module that can contain submodules

At runtime, both packages and ordinary modules are module objects.

import inspect
import json
import pathlib

assert inspect.ismodule(json)
assert inspect.ismodule(pathlib)

inspect.ismodule() therefore cannot distinguish them.

A package has an additional role in the import system: it supplies a __path__ that tells import machinery where its children may be found.

Python 3.14 exposes that distinction directly:

import inspect
import json
import pathlib

assert inspect.ispackage(json)
assert not inspect.ispackage(pathlib)

This makes intent clearer than scattering low-level attribute checks throughout application code.

The old check was usually __path__

Before Python 3.14, runtime code commonly used a check like this:

import types


def is_package(obj: object) -> bool:
    return isinstance(obj, types.ModuleType) and hasattr(obj, "__path__")

That is essentially the semantic distinction the import system uses: by definition, a module with __path__ is a package.

Python 3.14 lets application code say what it means instead:

import inspect

if inspect.ispackage(module):
    ...

The benefit is mostly readability and standardization, not a new package model.

When a future maintainer sees inspect.ispackage(module), they do not have to remember why a __path__ attribute is significant.

Do not classify packages by __file__

It is tempting to assume a package is a directory with an __init__.py file and therefore classify imported objects by filesystem layout.

That model is incomplete.

Namespace packages can exist without an __init__.py. Importers can also load modules from locations that are not ordinary source directories.

This is therefore the wrong abstraction:

from pathlib import Path


def looks_like_package(module) -> bool:
    filename = getattr(module, "__file__", None)
    if filename is None:
        return False
    return Path(filename).name == "__init__.py"

It couples runtime classification to one common storage representation.

If you already have an imported module object and want to know whether Python considers it a package, use the runtime predicate:

import inspect


def classify_module(module) -> str:
    if inspect.ispackage(module):
        return "package"
    if inspect.ismodule(module):
        return "module"
    return "not a module"

Namespace packages are an important reason to use import semantics

PEP 420 namespace packages allow one logical package to span multiple directories.

Such a package may not have a conventional __init__.py file at all. Its __path__ can represent multiple search locations.

Suppose two installed distributions contribute modules under the same namespace:

site-packages-a/
    acme/
        billing/
            __init__.py

site-packages-b/
    acme/
        search/
            __init__.py

The imported acme object can still be a package even though there is no single acme/__init__.py defining it.

Code based on source-file naming can misclassify this arrangement. Code based on package semantics does not need to care which importer produced the module.

import importlib
import inspect

acme = importlib.import_module("acme")

if inspect.ispackage(acme):
    print("acme can participate in submodule imports")

That is a more robust level of abstraction for tooling.

Detection happens after import

inspect.ispackage() accepts an object. It does not discover a package name without importing it.

This distinction matters.

import importlib
import inspect

module = importlib.import_module("myapp.plugins")
print(inspect.ispackage(module))

By the time the predicate runs, myapp.plugins has already been imported and its import-time code may already have executed.

Do not treat inspect.ispackage() as a safe preflight check for untrusted Python code.

If your real problem is discovering modules without executing them, work with import specifications or distribution metadata first and postpone imports until you have decided what is allowed to execute.

Package detection is not plugin validation

A plugin system might begin with package detection:

import inspect


def load_children(root):
    if not inspect.ispackage(root):
        raise TypeError(f"{root!r} is not a package")

That check is useful, but it proves only one thing: the object is a package.

It does not prove that:

  • the package came from a trusted distribution,
  • its children are safe to import,
  • its metadata is valid,
  • it implements your plugin protocol,
  • its import path is unique,
  • or importing every child is free of side effects.

Keep structural classification separate from trust and interface validation.

A simple plugin-package boundary

A plugin loader can use inspect.ispackage() to fail early when its configuration points at an ordinary module.

from __future__ import annotations

import importlib
import inspect
from types import ModuleType


def import_plugin_root(name: str) -> ModuleType:
    root = importlib.import_module(name)

    if not inspect.ispackage(root):
        raise ValueError(
            f"plugin root {name!r} must be a package, "
            "not an ordinary module"
        )

    return root

This creates a clear contract: a configured plugin root must be capable of containing children.

The next step—discovering those children—is a separate operation with separate failure modes.

Discover children with package-aware APIs

Once you have a package, pkgutil.iter_modules() can enumerate modules visible through its package path.

from __future__ import annotations

import inspect
import pkgutil
from types import ModuleType


def child_names(package: ModuleType) -> list[str]:
    if not inspect.ispackage(package):
        raise TypeError("expected a package")

    prefix = package.__name__ + "."

    return [
        info.name
        for info in pkgutil.iter_modules(package.__path__, prefix)
    ]

Notice the ordering of responsibilities:

  1. verify that the object is a package,
  2. use its package path for discovery,
  3. decide which discovered names are eligible,
  4. import only the names you intend to execute.

Do not collapse all four stages into one recursive import loop unless your application genuinely needs that behavior.

Discovery does not imply import

A common plugin-loader mistake is importing everything that can be found beneath a package.

for name in child_names(root):
    importlib.import_module(name)

This may be acceptable for a tightly controlled application package. It is a poor default for extensible environments.

Imports execute Python code. A discovered module can perform network requests, read environment variables, register signal handlers, create threads, modify global registries, or fail because an optional dependency is absent.

Prefer an explicit selection boundary:

ALLOWED = {
    "myapp.plugins.csv",
    "myapp.plugins.json",
}

for name in child_names(root):
    if name in ALLOWED:
        importlib.import_module(name)

For third-party ecosystems, entry-point metadata is often a better discovery mechanism than blindly walking an import tree.

inspect.ispackage() is about loaded module objects

The predicate should not be used as a general-purpose test for strings, paths, or module specifications.

These calls are conceptually wrong:

inspect.ispackage("json")
inspect.ispackage("src/myapp")

They return false because strings are not package module objects.

Keep the representation clear in your APIs.

from types import ModuleType


def inspect_loaded_module(module: ModuleType) -> None:
    ...

If your input is a module name, import or inspect its specification deliberately. If your input is a filesystem path, use filesystem APIs. If your input is an installed distribution, use package metadata APIs.

One predicate should not be stretched across all of those layers.

Imported modules commonly expose a __spec__ describing how they were loaded.

A specification can contain submodule_search_locations, which is relevant to package discovery. This is useful when you are operating at the import-spec layer.

But if you already have a live module and simply need a yes-or-no package predicate, inspect.ispackage() communicates the question more directly.

Choose the layer that matches the job:

installed distribution metadata
import name / ModuleSpec
loaded module object
application plugin object

Package detection on a live module belongs on the third layer.

Do not use it to infer facts that belong to the other layers.

Packages can customize their search path

A package’s __path__ is not merely a directory name.

Import machinery treats it as the search locations for submodules. Traditional packages can alter it, namespace packages can expose multiple locations, and custom importers can participate in module resolution.

This is another reason not to rewrite package semantics as:

Path(module.__file__).parent

That expression may happen to work for a conventional source package, but it discards the import system’s actual search model.

When traversing children, consume package.__path__ through import-aware APIs rather than guessing a parent directory.

A package may have no useful source file

Developer tools often want both package classification and source information.

Treat those as independent capabilities.

import inspect


def describe(module) -> dict[str, object]:
    return {
        "name": getattr(module, "__name__", None),
        "package": inspect.ispackage(module),
        "file": getattr(module, "__file__", None),
    }

Do not reject a package merely because __file__ is absent.

Likewise, the presence of a file does not by itself prove that a module is a package.

Tools should degrade gracefully when source locations are unavailable.

Import errors need context

A package walker that imports children should report which phase failed.

from __future__ import annotations

import importlib


def import_selected(names: list[str]):
    loaded = []

    for name in names:
        try:
            module = importlib.import_module(name)
        except Exception as exc:
            raise RuntimeError(
                f"failed while importing plugin candidate {name!r}"
            ) from exc
        loaded.append(module)

    return loaded

Do not convert every exception into “not a package.”

An import can fail because of syntax errors, missing transitive dependencies, runtime configuration, platform incompatibility, or arbitrary import-time application errors.

Package classification and import success are different dimensions.

Avoid catching BaseException around plugin imports

This is tempting:

try:
    module = importlib.import_module(name)
except BaseException:
    return None

It is usually too broad.

BaseException includes process-control exceptions such as KeyboardInterrupt and SystemExit. Swallowing them can make command-line tools difficult to stop and can hide deliberate termination.

Catch Exception when you intend to isolate ordinary plugin failures, then attach the plugin name and preserve the original exception as the cause.

For production plugin systems, decide explicitly whether one broken plugin should abort startup or be quarantined while other plugins continue.

Do not assume every package should be recursively walked

A package boundary does not promise that every descendant is public or importable in your current environment.

A tree can contain:

  • implementation-only modules,
  • platform-specific modules,
  • optional integrations,
  • generated modules,
  • expensive imports,
  • test helpers,
  • compatibility shims,
  • and modules that intentionally fail when optional dependencies are missing.

inspect.ispackage() tells you that children are meaningful in import semantics. It does not grant permission to enumerate and execute the entire tree.

Prefer explicit plugin metadata or a naming convention with a narrow scope.

Testing ordinary packages and modules

The core behavior is easy to test with standard-library examples, but application tests should focus on your own boundary.

import inspect
import json
import pathlib


def test_package_detection():
    assert inspect.ispackage(json)
    assert not inspect.ispackage(pathlib)

For a plugin loader, create fixture packages instead of depending entirely on standard-library layout.

A useful test tree might contain:

tests/fixtures/
    demo_plugins/
        __init__.py
        alpha.py
        nested/
            __init__.py
            beta.py
    ordinary_module.py

Then test both accepted and rejected roots.

Test namespace packages separately

If your application claims to support namespace packages, include one in the test suite.

Do not assume a regular package test covers it.

Create two temporary import roots that contribute to the same namespace, put different child packages in each, add both roots to the test import path, and verify that discovery sees the intended children.

This catches code that accidentally falls back to __file__, __init__.py, or one physical parent directory.

The exact temporary-directory setup depends on your test framework, but the invariant is simple: package logic should follow import semantics rather than one filesystem shape.

Test import-time failures

A robust discovery system should also have a candidate module that raises during import.

For example:

# broken_plugin.py
raise RuntimeError("simulated plugin initialization failure")

Verify that your loader:

  • identifies the failing module by name,
  • preserves the original exception,
  • follows the application’s fail-fast or quarantine policy,
  • and does not misreport the failure as a package-classification problem.

Failure-path tests are more valuable than another happy-path predicate assertion.

Test duplicate and shadowed names

Import behavior depends on search-path ordering.

If multiple locations can provide the same module name, your discovery system should not pretend that names uniquely identify filesystem locations independently of the import machinery.

Test the environment your application supports:

root-a/myapp_plugins/example.py
root-b/myapp_plugins/example.py

Then make the expected precedence explicit.

For security-sensitive extension systems, consider constraining where plugins may originate instead of accepting arbitrary sys.path shadowing.

Compatibility with Python before 3.14

inspect.ispackage() was added in Python 3.14.

If your library supports older Python releases, keep the compatibility decision at one boundary:

from __future__ import annotations

import inspect
import types


def is_package(module: object) -> bool:
    predicate = getattr(inspect, "ispackage", None)

    if predicate is not None:
        return predicate(module)

    return isinstance(module, types.ModuleType) and hasattr(module, "__path__")

Application code can then depend on is_package() without repeating version checks.

If your project requires Python 3.14 or newer, prefer the standard function directly.

Do not version-check when capability-checking is clearer

This works:

import sys

if sys.version_info >= (3, 14):
    ...

But a reusable compatibility layer often benefits from checking the capability it needs:

predicate = getattr(inspect, "ispackage", None)

That keeps the fallback tied to the API rather than to assumptions about interpreter versions.

Whichever style your project uses, centralize it and test both branches if both are supported.

Package identity is not distribution identity

One of the easiest mistakes in Python packaging tooling is treating an import package and an installed distribution as the same object.

They are not.

An installed distribution can provide multiple import packages. A namespace package can receive contributions from multiple distributions. Distribution names and import names can differ.

Therefore this conclusion is invalid:

inspect.ispackage(module) == True
    therefore
there is exactly one installed distribution with the same name

Use importlib.metadata when you need installed-distribution metadata. Use inspect.ispackage() when you need to classify a loaded module object.

Package detection is not a security boundary

A malicious or compromised package is still a package.

This code does not establish trust:

if inspect.ispackage(candidate):
    run_plugin(candidate)

The predicate says nothing about provenance, signatures, permissions, sandboxing, or authorization.

If plugins can come from users or third parties, define a separate trust model. Depending on the application, that might include an allowlist of distributions, isolated processes, restricted credentials, explicit installation workflows, or administrative approval.

Python imports execute code with the privileges of the Python process. Package classification does not change that fact.

Prefer narrow APIs over introspection everywhere

inspect.ispackage() is useful at infrastructure boundaries, but most business logic should not need to ask whether arbitrary objects are packages.

A plugin manager can normalize discovery once:

class PluginRegistry:
    def __init__(self, plugins):
        self._plugins = tuple(plugins)

    def all(self):
        return self._plugins

Downstream code can work with validated plugin objects instead of repeatedly inspecting import structure.

This keeps Python’s import model at the edge of the system rather than leaking it into unrelated application code.

A practical discovery design

For a controlled plugin namespace, a maintainable design looks like this:

from __future__ import annotations

import importlib
import inspect
import pkgutil
from types import ModuleType


def load_plugin_package(root_name: str) -> ModuleType:
    root = importlib.import_module(root_name)

    if not inspect.ispackage(root):
        raise TypeError(f"{root_name!r} is not a package")

    return root


def discover_direct_children(root: ModuleType) -> list[str]:
    if not inspect.ispackage(root):
        raise TypeError("root must be a package")

    prefix = root.__name__ + "."
    return sorted(
        info.name
        for info in pkgutil.iter_modules(root.__path__, prefix)
    )


def import_allowed(names: list[str], allowed: set[str]) -> list[ModuleType]:
    modules = []

    for name in names:
        if name not in allowed:
            continue
        modules.append(importlib.import_module(name))

    return modules

Each function has one job.

The first validates the configured root. The second discovers direct children without pretending they are all approved. The third applies policy before importing candidates.

That separation makes testing and security review much easier than a recursive “find and import everything” helper.

When inspect.ispackage() is the right tool

Use it when all of these are true:

  • you already have a live Python object,
  • you need to distinguish packages from ordinary modules,
  • package semantics matter to the next operation,
  • and Python 3.14 is available or you have a compatibility wrapper.

It is especially natural in import tooling, plugin infrastructure, interactive inspectors, documentation tools, and test discovery code.

Do not use it when your real input is a path, a distribution name, or an unimported module name and a lower-level discovery API can answer the question without executing code.

The small predicate encodes a useful boundary

inspect.ispackage() is not one of Python 3.14’s largest features. It is valuable because it gives a common import-system concept a direct, readable predicate.

Instead of asking whether a module happens to have a particular source filename or manually checking import attributes everywhere, code can state the question plainly:

if inspect.ispackage(module):
    discover_children(module)

The important engineering work happens around that line.

Keep discovery separate from import. Support namespace packages if you claim to. Do not infer distribution identity from package identity. Preserve import failures accurately. Treat plugin execution as a trust decision rather than a type check.

With those boundaries in place, inspect.ispackage() becomes exactly what a good introspection API should be: a small primitive that makes larger tooling easier to read and harder to misunderstand.