Python · Python Essentials

Python: Modules, Imports and Singletons

What runs at import and why a module is already a singleton, how from-imports bind names, circular imports, reload and module-level getattr, and singletons that re-run init.

  • 8 min read
  • 12 practice questions
On this page
  1. Description
  2. What an import does
  3. What an import gives you
  4. Running as a script: __main__
  5. Circular imports
  6. Singletons in Python
  7. Reloading, and module __getattr__
  8. Which tool for which job?
  9. Common mistakes
  10. Check yourself
  11. Key takeaways
  12. Quick revise
  13. Practice

Description

Every Python program is a set of modules - files that import each other. Most of the time imports just work, so it's easy to never think about them. But a few interview questions, and a surprising number of production bugs, come down to what an import actually does: when a module's code runs, what from x import y really gives you, why two modules that import each other can fail, and why the simplest singleton in Python is just a module.

Here's the kind of bug we mean. An online store keeps its feature flags in a module, and an admin page reloads them:

# flags.py
settings = load_flags()

# checkout.py
from flags import settings

def use_new_checkout() -> bool:
    return settings["new_checkout"]

When an admin changes a flag, the admin page runs flags.settings = load_flags(). Code that reads flags.settings sees the new value at once. checkout.py never does - it keeps reading the old dictionary until the server restarts. By the end of the second section you'll know why.

We'll go from simple to more technical:

  1. What happens when you import a module - and why its code runs only once.
  2. What import x and from x import y bind, and why it matters for settings and tests.
  3. if __name__ == "__main__": and circular imports.
  4. Singletons in Python: the module, functools.cache, and the __new__ trap.
  5. importlib.reload and module-level __getattr__.

What an import does

The first time a module is imported, Python:

  1. finds the file,
  2. creates an empty module object and stores it in sys.modules under the module's name,
  3. runs the file's top-level code, top to bottom, inside that module object.

Every later import of the same name finds it in sys.modules and returns the same object without running anything. So a module's top-level code runs once per process:

# config.py
print("loading config")
settings = {"env": "dev"}

Import config from ten different files and "loading config" prints once, and all ten share the one settings dictionary.

That makes a module the natural home for things there should be one of: configuration, a connection pool, a logger. Imports are also thread-safe: if two threads import the same module at once, the second waits until the first has finished running it.

What an import gives you

The two forms bind different things:

  • import config binds the name config to the module. Each time you write config.settings, Python looks the attribute up again.
  • from config import settings looks settings up once, at import time, and binds your own name to that object.
import config
from config import settings

config.settings = {"env": "prod"}   # the module's name now points at a new dict
print(settings)                     # {'env': 'dev'} - your name still holds the old one
print(config.settings)              # {'env': 'prod'}

That's the feature-flag bug: checkout.py used from flags import settings, so replacing flags.settings never reached it. Two ways out:

  • Import the module, not the name, for anything that can be replaced while the program runs: import flags, then flags.settings[...].
  • Change the object instead of replacing it: flags.settings.update(load_flags()) changes the one dictionary every name points at.

The same rule explains a classic testing mistake. unittest.mock.patch("payments.charge") replaces the name charge in the payments module - but if checkout.py did from payments import charge, it holds its own name, and the patch doesn't reach it. Patch where the name is used: patch("checkout.charge").

Running as a script: __main__

Every module has a __name__. When a file is imported, that's its module name ("config"). When you run a file directly - python app.py - Python runs it under the name "__main__". That's what this common guard checks:

def main():
    ...

if __name__ == "__main__":
    main()          # runs for "python app.py", not for "import app"

Use it in every file that is both a script and a module. It's essential with multiprocessing: on macOS and Windows (and by default on Linux since Python 3.14) worker processes start a fresh interpreter and import your main file, and without the guard each worker would run your startup code again and try to start workers of its own.

Circular imports

If two modules import each other at the top, one of them runs while the other is only half finished:

# shop/orders.py
from shop import payments
TAX_RATE = 0.2

# shop/payments.py
from shop.orders import TAX_RATE    # orders hasn't reached this line yet

Importing shop.orders starts running it, which imports shop.payments, which asks for TAX_RATE - but orders stopped at its first line to import payments, so TAX_RATE doesn't exist yet. Python says so quite clearly: ImportError: cannot import name 'TAX_RATE' from partially initialized module 'shop.orders' (most likely due to a circular import).

Fixes, best first:

  • Move the shared name into a third module that both import (shop/rates.py). A cycle usually means two modules share something that belongs to neither.
  • Import inside the function that needs it, so the import happens when the function runs - long after both modules have finished loading.
  • Use import shop.orders and read shop.orders.TAX_RATE later, inside functions. The module object exists (half-built) during the cycle; only reading the attribute too early fails.

Singletons in Python

Java needs a pattern for "exactly one instance". In Python a module already is one, so the simplest singleton is an instance created at the top of a module:

# config.py
settings = Settings.from_env()     # created once, shared by every importer

# clients.py - or created lazily, on first use
from functools import cache

@cache
def get_client() -> ApiClient:
    return ApiClient(...)

The Java-style alternative, overriding __new__ to return a cached instance, has a trap: Python calls __init__ on whatever __new__ returns, every time you call the class. So each Registry() gives you the same object - with its state wiped by __init__:

class Registry:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def __init__(self):
        self.items = []            # runs again on every Registry() call

r1 = Registry(); r1.items.append("a")
r2 = Registry()
print(r1 is r2, r1.items)          # True []

And any lazy "create it if it's missing" check - if _instance is None: _instance = Registry() - can run in two threads at once, and both create one. Take a lock, use @cache on a function, or create the object at import time, where the import lock already makes it safe.

Reloading, and module __getattr__

importlib.reload(module) runs the module's code again inside the same module object. Every function and class in it is rebuilt as a new object. Objects created before the reload still belong to the old class, so isinstance(old_circle, shapes.Circle) becomes False - and modules that did from shapes import Circle still hold the old class. Reload is handy in a notebook; it's not a way to update a running server.

A module-level __getattr__ (Python 3.7+) runs when someone asks the module for a name it doesn't have. Two good uses: loading something heavy only when it's first used, and keeping an old name working while you rename it:

import warnings

def new_name():
    ...

def __getattr__(name):
    if name == "old_name":
        warnings.warn("old_name is deprecated; use new_name", DeprecationWarning, stacklevel=2)
        return new_name
    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

Which tool for which job?

You wantUse
One shared instancea module-level object
One instance, created on first usea @functools.cache function (or a lock)
Settings that can change at run timeimport config and read config.settings each time
Code that runs only as a scriptif __name__ == "__main__":
Two modules that need each othermove the shared part out, or import inside the function
Patching a dependency in a testpatch("<module that uses it>.<name>")
A renamed or lazily loaded attributemodule-level __getattr__

Common mistakes

MistakeWhat happensDo this instead
from config import settings, then replacing config.settingsyour name keeps the old objectimport config, or change the object in place
Patching where a function is definedcode that imported the name isn't patchedpatch where it's used
Two modules importing each other at the topImportError from a partially initialized modulea third module, or a local import
No __main__ guard with multiprocessingworkers re-run the script's top levelput startup code under the guard
A __new__ singleton with an __init__state is reset on every calla module-level instance or @cache
A lazy singleton without a locktwo threads create two instancesa lock, or create it at import time
importlib.reload in a running serviceold objects keep the old classesrestart the process

Check yourself

Try to answer before opening each one. Recalling beats re-reading.

1. Why does checkout.py never see the new flags?

Show answer

from flags import settings bound checkout's own name to the dictionary flags.settings held at import time. flags.settings = load_flags() moved only the module's name to a new dictionary. Use import flags and read flags.settings, or update the dictionary in place.

2. Ten modules import config. How many times does config.py's top-level code run?

Show answer

Once. The first import runs it and stores the module in sys.modules; the other nine get the same module object.

3. Why does Registry() keep returning an empty registry, even though it's the same object each time?

Show answer

__new__ returns the cached object, but Python then calls __init__ on it, and __init__ sets self.items = [] again. Use a module-level instance or a cached factory function instead.

Key takeaways

  • An import finds the file, caches the module in sys.modules and runs its code once; later imports reuse it.
  • import x looks attributes up every time; from x import y binds the object once. Patch where a name is used.
  • Guard script code with if __name__ == "__main__":.
  • Break import cycles by moving shared code out or importing inside functions.
  • A module-level instance is Python's singleton; __new__ singletons re-run __init__, and lazy ones need a lock.
  • reload makes new classes; module __getattr__ handles renamed and lazy attributes.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the tool

You wantUse
One shared instancea module-level object (created under the import lock)
One instance, created on first usea @functools.cache function, or a lock around the check
Settings that change at run timeimport config, read config.settings each time
Code that runs only as a scriptif __name__ == "__main__":
Two modules that need each othera third module for the shared part, or a local import
Patching a dependency in a testpatch("<where it's used>.<name>")
A renamed or lazy attributemodule __getattr__
Find slow importspython -X importtime app.py
Run a module inside a packagepython -m app.tool

Snippets worth knowing by heart

import config                       # looks config.settings up every time
from config import settings         # binds the object once, at import time

if __name__ == "__main__":
    main()

from functools import cache

@cache
def get_client():
    return ApiClient()

def __getattr__(name):              # in a module: called for missing names
    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

The traps

  • from x import y doesn't see a later x.y = new - import the module, or change the object in place.
  • patch("payments.charge") misses code that did from payments import charge - patch where it's used.
  • Top-level imports in both directions - ImportError from a partially initialized module.
  • No __main__ guard - multiprocessing workers re-run your startup code.
  • A __new__ singleton - __init__ runs on every call and resets state.
  • A lazy singleton without a lock - two threads create two.
  • importlib.reload - new classes; old objects fail isinstance.
  • One file under two names - two modules, two singletons.

Staff-level points, one line each

  • Import order: sys.modules → parent package → sys.meta_path finders (builtin, frozen, path) → module stored in sys.modules before it runs.
  • So import a works in a cycle (the half-built module is returned); from a import x fails reading x too early.
  • A module whose code raises is removed from sys.modules; the next import runs it again, side effects and all.
  • 3.13+: a missing name in a top-level file beside your script suggests renaming it (stdlib shadowing).
  • Per-module import locks make module-level singletons thread-safe; starting and joining a thread that imports the same module at import time hangs.
  • python app.py loads the file as __main__; import app loads it again - keep entry scripts thin, use python -m.
  • .pyc files live in __pycache__/<name>.cpython-314.pyc, checked by source mtime and size (or a hash, PEP 552); they save compile time, not import time.
  • PYTHONDONTWRITEBYTECODE=1 means recompiling every start - compileall at build time.
  • -X importtime (and =2 on 3.14 for cached imports) finds slow imports; PEP 810 brings lazy import in 3.15.
  • functools.cache may call the function twice under a race - use a lock when "exactly once" matters, especially on free-threaded builds.

Say it in an interview

  • "I'll keep the client as a module-level instance - imports run once and the import lock makes creation thread-safe."
  • "Tests patch checkout.charge, because checkout imported the name."
  • "This cycle means both modules share something; I'll move it into its own module."
  • "Startup is slow, so I'll measure with -X importtime and defer the heavy imports."

Practice

This skill comes with 12 multiple-choice questions that tell you which misconception a wrong answer reveals. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Python: Modules, Imports and Singletons free

Practice Python: Modules, Imports and Singletons free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime