Skip to content

Packages & __main__ Interview Questions & Answers

15 questions Updated 2026-06-18 Share:

Python interview questions on packages vs modules, the role of __init__.py, python -m and __main__.py, __all__, namespace packages, and running a script versus importing it.

Read the in-depth guidePython Packages & __main__ Explained — __init__.py, Running Modules, and Package Layout(opens in new tab)
15 of 15

A module is a single .py file. A package is a directory of modules that you import as a unit, traditionally marked by an __init__.py file. That file runs when the package is first imported, so it's where you can set the package's public API or do setup; it's often empty, which is fine.

# myapp/                 <- package
#   __init__.py          <- marks it a package, runs on import
#   db.py                <- module
#   utils/               <- subpackage
#       __init__.py
#       text.py

import myapp.db                 # import a module from the package
from myapp.utils.text import slug

A common use of __init__.py is to re-export so callers can write from myapp import db cleanly. Modules organize code into files; packages organize modules into a namespace tree.

python -m pkg.module runs a module as a script while still locating it via the import system (so relative imports and the package context work). When you point -m at a package, Python runs that package's __main__.py — that's how a package becomes executable, like python -m http.server.

python -m http.server 8000     # runs http.server's __main__.py
python -m myapp                # runs myapp/__main__.py
python -m pytest               # run an installed tool as a module
# myapp/__main__.py
from myapp.cli import main
main()

Use -m to run installed/packaged code by its import name rather than a file path, which avoids the sys.path surprises you get from running a file directly.

__all__ is a list of names that defines a module's (or package's) public API for wildcard imports — from module import * imports exactly those names. Without it, import * grabs every name not starting with an underscore.

# mymath.py
__all__ = ["add", "PI"]      # only these are exported by *

def add(a, b): return a + b
def _helper(): ...           # private anyway
PI = 3.14159

# elsewhere
from mymath import *         # gets add and PI only

It does not prevent explicit imports (from mymath import _helper still works) — it only curates * and documents intent. Define __all__ to keep import * clean and to signal what's officially public.

A namespace package (PEP 420) is a package with no __init__.py whose contents can be split across multiple directories on sys.path. Python merges those directories into one logical package — useful for plugins where different distributions contribute to a shared top-level namespace.

# path1/acme/foo.py
# path2/acme/bar.py     (no __init__.py in either acme/)
# with both paths on sys.path:
import acme.foo
import acme.bar          # both resolve under the merged 'acme' namespace

Since Python 3.3, a directory without __init__.py can still be importable as a namespace package. For an ordinary single-location package you usually still want a regular package (with __init__.py); reserve namespace packages for splitting a namespace across separately-installed parts.

When you run a file (python foo.py), Python sets its __name__ to "__main__". When you import it, __name__ is the module's name. The if __name__ == "__main__": guard uses this so a file can act as both a runnable script and an importable library — the guarded code runs only on direct execution.

# greet.py
def hello(name): return f"Hi {name}"

if __name__ == "__main__":   # runs only via `python greet.py`
    print(hello("world"))    # NOT run when `import greet`

Without the guard, your script's top-level side effects (running, printing, parsing args) would fire every time the module is imported. Always put script entry points behind the __main__ guard.

An import package is what you import in code (import requests). A distribution package is what you pip install (the project on PyPI). Their names usually match but don't have to — one distribution can ship several import packages, and the names can differ entirely.

pip install beautifulsoup4     # distribution name
import bs4                      # import name — different!
pip install scikit-learn       # -> import sklearn

Rule of thumb: the name you pip install and the name you import are separate identifiers; check a project's docs when they don't match.

pyproject.toml is the standardized, declarative project config (PEP 517/518): it declares the build backend, dependencies, and metadata in one file. It replaced executable setup.py scripts, making builds reproducible and tool-agnostic.

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "mypkg"
version = "1.0.0"
dependencies = ["requests>=2.0"]

Rule of thumb: new projects use pyproject.toml with a build backend (hatchling, setuptools, flit); setup.py is legacy and runs arbitrary code at build time.

An editable install links your project into the environment in place instead of copying it, so code edits take effect without reinstalling. It's the standard way to develop a package locally while importing it like an installed one.

pip install -e .          # install current project in editable mode
# edit source files -> changes are picked up immediately on next import

Rule of thumb: use pip install -e . during development so your working tree is the installed package; use a regular install for deployment.

Declare a console script entry point in pyproject.toml. On install, pip generates an executable that calls your function — turning a package into a CLI command available on PATH.

[project.scripts]
mytool = "mypkg.cli:main"     # creates a `mytool` command -> calls main()
# mypkg/cli.py
def main():
    print("hello from mytool")

Rule of thumb: use [project.scripts] entry points (not shebang hacks) to ship CLI commands; pip creates the wrapper executable for the user's platform.

Use importlib.resources rather than building paths from __file__ — it works even when the package is zipped or installed oddly. Declare the data files in your build config so they're included in the distribution.

from importlib.resources import files

# read mypkg/data/config.json shipped inside the package:
text = files("mypkg.data").joinpath("config.json").read_text()

Rule of thumb: load bundled resources with importlib.resources, not open(os.path.join(os.path.dirname(__file__), ...)), which breaks for zipped/egg installs.

Relative imports (from . import x, from ..pkg import y) only work when the module is part of a package with a known parent — i.e. imported or run via python -m. Running the file directly (python sub/mod.py) makes it __main__ with no package, so they raise ImportError.

# mypkg/sub/mod.py
from ..utils import helper      # works via `python -m mypkg.sub.mod`
                                 # fails via `python mypkg/sub/mod.py`

Rule of thumb: relative imports need package context — run package code with python -m pkg.module, not by file path, or use absolute imports.

A subpackage is a package inside a package — each level is a directory (with __init__.py for regular packages). You import down the tree with dotted paths, and each __init__.py runs as its level is first imported.

myapp/
  __init__.py
  api/
    __init__.py
    routes.py
from myapp.api.routes import handler
import myapp.api.routes          # imports myapp, then myapp.api, then routes

Rule of thumb: importing a.b.c initializes every package along the path (a, then a.b, then a.b.c) in order, top down.

Defining __getattr__(name) at module level (PEP 562) intercepts access to missing module attributes — used for lazy imports, deprecation warnings, or computed attributes, without loading everything at import time.

# mypkg/__init__.py
def __getattr__(name):
    if name == "heavy":
        import mypkg._heavy as h    # load only when accessed
        return h
    raise AttributeError(name)

# mypkg.heavy triggers the lazy load on first access

Rule of thumb: module __getattr__ powers lazy submodule loading and deprecation shims — it runs only for names not already defined in the module.

Re-export key names in __init__.py so users import from the package root instead of deep module paths. Pair with __all__ to define the official surface.

# mypkg/__init__.py
from .client import Client
from .errors import ApiError
__all__ = ["Client", "ApiError"]

# users write:
from mypkg import Client        # not mypkg.client.Client

Rule of thumb: curate the top-level API via __init__.py re-exports so internal module layout can change without breaking users' imports.

Heavy __init__.py re-exports can create cycles: importing the package triggers __init__.py, which imports submodules that import the package back. The fix is to defer or restructure imports, or keep __init__.py light.

# mypkg/__init__.py
from .a import A      # imports a.py
# mypkg/a.py
from mypkg import B   # cycle! mypkg's __init__ isn't finished yet

Rule of thumb: avoid importing the package from its own submodules; import siblings directly (from mypkg.b import B) or defer the import inside a function.

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel