Tutorial: Dynamic Class Discovery and Loading in Python



This content originally appeared on DEV Community and was authored by Konstantinos Andreou

Introduction

When building modular Python applications, you often need a way to automatically discover and load classes without manually updating imports every time a new feature is added. Think of plugin systems, task runners, or contract/submission workflows — adding a new class should just work without touching the loader logic.

In this tutorial, I’ll walk you through a utility class I built — BaseDynamicLoader — that scans a given module, discovers all subclasses of a target base class, and makes them available for use in your application. This approach eliminates repetitive boilerplate and enables your code to scale dynamically as new modules and classes are introduced.

We’ll break down how the loader works, why it’s useful, and how you can extend it to fit your own project.

Prerequisites

Before diving in, make sure you’re comfortable with:

  • Python modules and imports (importlib)
  • Object-oriented programming concepts (inheritance, subclasses)
  • Basic file system traversal (os.walk)

The Problem to Solve

Let’s say we have a growing number of subclasses spread across multiple files. Each time we add a new class normally we would have to update a central registry or import list. Instead, you want your system to discover classes automatically and make them available.

Consider the following project structure for our example. We will explain each file in detail later on.

app
├── classes
│   ├── __init__.py
│   ├── dynamicLoader.py
│   ├── animal.py
│   └── animalLoader.py
├── animals
│   ├── __init__.py
│   ├── dog.py
│   ├── cat.py
│   ├── cow.py
│   └── # ...other animals can be added here
└── main.py
  • This project implements a number of animals each in a different file, inheriting from the base class Animal. This is a simple base class that defines common properties and methods for all animals:
# app/classes/animal.py
class Animal:
    def __init__(self, name: str, species: str):
        self.name = name
        self.species = species
        self.sound = None

    def make_sound(self):
        if self.sound:
            print(f"{self.name} the {self.species} says {self.sound}")
  • Now let’s implement the different animals. I will not write here the full code for each animal, as this isn’t the purpose of the post but you can follow the same pattern as the Dog class.
# app/animals/dog.py
from app.classes.animal import Animal


class Dog(Animal):
    def __init__(self, name: str):
        super().__init__(name, species="Dog")
        self.sound = "Bark"

Now it’s time that we dive into the implementation of the dynamic loader:

The BaseDynamicLoader Class

The BaseDynamicLoader class provides a reusable framework for automatically discovering and loading subclasses of a given base class within a specified module. It works by first importing the target module (defined as the MODULE attribute in subclasses) and then scanning all of its Python files using os.walk. For each file, it dynamically imports the module with importlib.import_module, inspects its contents, and checks whether any defined objects are subclasses of the target base class (class_to_find, which in this example is bound to Animal). If a valid subclass is found, it is added to an internal dictionary _found_classes, keyed by the class name and mapped to the class object itself. Logging is integrated throughout the process to provide visibility into which classes were found and loaded, making debugging easier. By encapsulating this discovery mechanism in a single class, you eliminate the need to manually import and register new subclasses every time the codebase grows, turning your application into a more extensible and plugin-friendly system.

# app/classes/dynamicLoader.py

import importlib, os
from typing import List, Dict, TypeVar
import logging
from app.classes.animal import Animal


logger = logging.getLogger()

T = TypeVar("T", bound=Animal)


class BaseDynamicLoader:
    MODULE: str

    def __init__(self, class_to_find: T):
        self.module = importlib.import_module(self.MODULE)
        self.class_to_find = class_to_find
        self._found_classes = self.__dynamic_class_loader()

    def __dynamic_class_loader(self) -> Dict[str, T]:
        """
        Dynamically load all classes classes from the module.
        """
        found_classes: Dict[str, T] = {}
        logger.info(
            f"{self.__class__.__name__}: Loading {self.class_to_find.__name__} classes from <{self.MODULE}>"
        )
        root_dir = self.module.__path__[0]
        for root, _, files in os.walk(root_dir):
            for file in files:
                # Check if the file is a Python file and not a special file
                if file.endswith(".py") and not file.startswith("__"):
                    mod_name = os.path.splitext(file)[0]
                    module = importlib.import_module(f"{self.MODULE}.{mod_name}")
                    for cls in dir(module):
                        # Check if the class is a subclass of class_to_find
                        if (
                            isinstance(getattr(module, cls), type)
                            and issubclass(getattr(module, cls), self.class_to_find)
                            and getattr(module, cls) is not self.class_to_find
                        ):
                            # Check if the class is a class_to_find
                            logger.info(
                                f"{self.__class__.__name__}: Found {self.class_to_find.__name__} class <{cls}> in <{file}>"
                            )
                            found_classes[cls] = getattr(module, cls)
        return found_classes

How the Loader Works (Step by Step)

  1. Initialize the Loader

    • You create a subclass of BaseDynamicLoader and define the MODULE you want to scan.
    • You pass in the base class you’re interested in (e.g., Animal).
  2. Import the Target Module

    • The loader imports the module dynamically using importlib.import_module(self.MODULE).
  3. Walk Through the Module’s Files

    • It traverses the directory where the module lives with os.walk, looking for .py files that are not special files (__init__.py, etc.).
  4. Import Each Submodule

    • For every Python file, it imports the submodule dynamically.
  5. Inspect Classes

    • It uses dir(module) to look at every attribute in the submodule.
    • If the attribute is a class and it’s a subclass of the target base class (but not the base class itself), it’s a match.
  6. Register the Class

    • Matching classes are stored in _found_classes, a dictionary mapping the class name to the class object.
  7. Log the Process

    • Throughout the process, the loader logs what it is scanning and which subclasses were found.

Using the BaseDynamicLoader class to implement our loader

Now, what we need to do is create a concrete implementation of the BaseDynamicLoader class for our specific use case. This involves defining the MODULE attribute and the base class we want to find subclasses of.

Here’s how we can do it:

# app/classes/animalLoader.py

from app.classes.dynamicLoader import BaseDynamicLoader
from app.classes.animal import Animal

from typing import List


class AnimalLoader(BaseDynamicLoader):
    MODULE = "app.animals"

    def __init__(self):
        super().__init__(class_to_find=Animal)

    @property
    def animals(self) -> List[type[Animal]]:
        return [cls for cls in self._found_classes.values()]

The AnimalLoader class is a concrete implementation of the generic BaseDynamicLoader. Its role is to specialize the base loader so that it specifically discovers all subclasses of the Animal class inside the app.animals module. By defining the MODULE attribute as "app.animals", the loader knows exactly where to look for new classes. In the constructor (__init__), it calls super().__init__(class_to_find=Animal), which initializes the base class logic and starts the discovery process, searching for all subclasses of Animal.

Additionally, AnimalLoader exposes a convenient animals property, which returns a list of all the discovered subclasses as class objects. This makes it easy to iterate over them, instantiate them, or otherwise integrate them into the application without having to interact directly with the internal _found_classes dictionary.

In practice, this means you can simply drop a new subclass of Animal into the app/animals/ folder—for example, Dog, Cat, or Bird—and the AnimalLoader will automatically find and load it. You never have to update your loader or maintain a manual registry. This keeps your application modular, extensible, and easy to maintain as new animals (or plugins, in other contexts) are introduced.

Loader usage

# main.py
from app.classes.animalLoader import AnimalLoader


loader = AnimalLoader()

for i, animal in enumerate(loader.animals):
    animal_instance = animal(f"Buddy {i}")
    animal_instance.make_sound()

Running the above snippet will produce the following output:

Buddy 0 the Cat says Meow
Buddy 1 the Cow says Moo
Buddy 2 the Dog says Bark


This content originally appeared on DEV Community and was authored by Konstantinos Andreou