Contents
  1. The Problem with Eager Initialisation
  2. Lazy Initialisation: Load on First Use
  3. The Singleton Pattern: One Instance Shared Across All Calls
  4. Using @property for a Cleaner Interface
  5. When to Use Lazy Initialisation
  6. What You Can Do Now
← All posts

Lazy Initialisation in Python: Only Load What You Need, When You Need It

Lazy initialisation defers the creation of an expensive object until the first time it is actually used. Understanding when and how to apply it prevents unnecessary work at startup and keeps objects lightweight until they need to be heavy.

Lazy initialisation is the practice of deferring the creation of an object or the execution of an expensive operation until the first time it is actually needed. The opposite, eager initialisation, creates everything upfront when the class is instantiated, regardless of whether those resources are ever used.

The distinction matters when initialisation is expensive: opening a database connection, loading a large file into memory, establishing a network connection, or instantiating a heavy model. Doing that work at startup costs time and memory even when the code path that needs it is never reached.

The Problem with Eager Initialisation

Consider a SaiyanWarrior class that loads a full power level registry from a remote source the moment it is created. Not every warrior will need to access the registry. Some are created just to check a name or compare a rank.

class SaiyanWarrior:
    def __init__(self, name):
        self.name = name
        self.power_registry = self._load_registry()  # always runs, always costs

    def _load_registry(self):
        print(f"Loading full power registry for {self.name}...")
        # simulate expensive operation
        return {"Goku": 9001, "Vegeta": 8500, "Gohan": 7800, "Piccolo": 4000}

    def get_power(self):
        return self.power_registry[self.name]


goku = SaiyanWarrior("Goku")     # registry loaded
vegeta = SaiyanWarrior("Vegeta") # registry loaded again
# Both paid the cost even if get_power() is never called

Every instantiation triggers the expensive load. If you create ten warriors to display their names in a list, you pay the registry cost ten times.

Lazy Initialisation: Load on First Use

The fix is to set the expensive attribute to None in __init__ and initialise it inside the method that actually needs it, checking first whether it already exists.

class SaiyanWarrior:
    def __init__(self, name):
        self.name = name
        self._power_registry = None  # not loaded yet

    def _load_registry(self):
        print(f"Loading full power registry for {self.name}...")
        return {"Goku": 9001, "Vegeta": 8500, "Gohan": 7800, "Piccolo": 4000}

    def get_power(self):
        if self._power_registry is None:
            self._power_registry = self._load_registry()  # loaded only here
        return self._power_registry[self.name]


goku = SaiyanWarrior("Goku")    # cheap — no registry load
vegeta = SaiyanWarrior("Vegeta") # cheap — no registry load

print(goku.get_power())   # registry loads NOW, on first actual need
print(goku.get_power())   # registry already loaded, returns immediately

The registry is loaded exactly once per instance, and only if get_power() is ever called. If it is never called, the cost is never paid.

The Singleton Pattern: One Instance Shared Across All Calls

Sometimes the expensive resource should be shared across all instances rather than created per instance. A database connection pool, a loaded configuration file, or a network client does not need to exist once per object. It should exist once per application.

This is the lazy singleton pattern: a class-level variable that is initialised on first use and reused for every subsequent call.

class PowerRegistry:
    _instance = None  # class-level, shared across all instances

    @classmethod
    def get(cls):
        if cls._instance is None:
            print("Initialising power registry (once only)...")
            cls._instance = {"Goku": 9001, "Vegeta": 8500, "Gohan": 7800, "Piccolo": 4000}
        return cls._instance


class SaiyanWarrior:
    def __init__(self, name):
        self.name = name

    def get_power(self):
        registry = PowerRegistry.get()  # first call loads, all others reuse
        return registry[self.name]


goku = SaiyanWarrior("Goku")
vegeta = SaiyanWarrior("Vegeta")

print(goku.get_power())    # "Initialising power registry (once only)..." then 9001
print(vegeta.get_power())  # no init message — reuses the existing instance, returns 8500

The registry is created once, the first time any warrior calls get_power(). Every warrior after that reuses the same object.

Using @property for a Cleaner Interface

The if self._x is None check inside every method that needs the lazy attribute is repetitive. Python’s @property decorator lets you encapsulate the lazy check so the caller never sees it.

class SaiyanWarrior:
    def __init__(self, name):
        self.name = name
        self._power_registry = None

    @property
    def power_registry(self):
        if self._power_registry is None:
            print(f"Loading registry for {self.name}...")
            self._power_registry = {"Goku": 9001, "Vegeta": 8500, "Gohan": 7800}
        return self._power_registry

    def get_power(self):
        return self.power_registry[self.name]  # accesses property, not raw attribute

    def top_warrior(self):
        return max(self.power_registry, key=self.power_registry.get)  # same property


goku = SaiyanWarrior("Goku")
print(goku.get_power())    # triggers load on first property access
print(goku.top_warrior())  # already loaded, no second load

Any method that accesses self.power_registry gets the lazy behaviour automatically. The load happens once, on the first property access, regardless of which method triggered it.

When to Use Lazy Initialisation

Use it when the cost of initialisation is non-trivial and the attribute may not always be needed:

  • Database or network connections
  • Loading files or models into memory
  • Making API calls that populate data
  • Parsing large configurations

Do not use it for simple values like strings, integers, or small dictionaries. The None check adds a small overhead on every access and makes the code slightly harder to read. Reserve lazy initialisation for cases where the deferred cost is meaningfully larger than the check itself.

What You Can Do Now

Take the following eager class and convert it to lazy initialisation using the @property pattern:

import json

class SaiyanBattleLog:
    def __init__(self, warrior_name):
        self.warrior_name = warrior_name
        # eager: always reads from disk on init
        with open("battle_log.json") as f:
            self._log = json.load(f)

    def victories(self):
        return self._log.get(self.warrior_name, [])

Your goal:

  1. Move the file read out of __init__
  2. Store _log = None in __init__
  3. Wrap the load in a @property that checks if self._log is None before reading
  4. Make victories() access the property instead of the raw attribute

Once done, instantiating SaiyanBattleLog will cost nothing. The file is only read the first time victories() is called, and only once per instance after that.

← All posts