- 1. はじめに
- 2. PEP 420 と2種類のパッケージ — 誤解の起源
- 3. 今回のパッケージのディレクトリ構成図
- 4. sys.path はどう作られるか — __init__.py の検証に入る前に
- 5. 空の __init__.py は何を宣言しているのか
- 6. 省略すると実際に何が起きるか — 手を動かして検証した
- 7. まとめ
- 8. さいごに
- 参照
1. はじめに
iimonでエンジニアをしている齋藤です。
Python でパッケージを作るとき、ディレクトリに今までなんとなく __init__.py を置いていました。お作法的なものだからとあまり理解しないで使用していました。ところが実際には、__init__.py を置き忘れても import は普通に通ります。試しに消してみても、手元のコードは何事もなく動く。「Python 3.3 以降は不要になった」と書かれた記事も見かけます。
__init__.pyの置いたほういいのか、置かなくてもいいのか、、今回は実際に動くパッケージを作って検証しました。結論を先に言うと置いたほうがいいです。中身が空の __init__.py は大事な役割をもちます。この記事ではなぜ中身が空の __init__.py が大事なのかまとめていきます
2. PEP 420 と2種類のパッケージ — 誤解の起源
「__init__.py は不要になった」という言説が広まった発端は、Python 3.3 の PEP 420(Implicit Namespace Packages)です。この PEP で、import の挙動が実際にこう変わりました。
PEPとは... Python 言語そのものに関する設計文書・提案書
PEP 420 の Discussion 節にあります。
Note that an ImportWarning will no longer be raised for a directory lacking an
__init__.pyfile. Such a directory will now be imported as a namespace package, whereas in prior Python versions an ImportWarning would be raised.
__init__.pyを欠いたディレクトリに対してImportWarningはもはや送出されない。そうしたディレクトリは名前空間パッケージとして import されるようになった(以前のバージョンでは警告が出ていた)
たしかに、__init__.py がなくても警告すら出ずに import が通るようになりました。この「警告すら出ない」という体験から、「もう書かなくていいのだ」という理解が広まっていきます。しかし PEP 420 が導入したのは、__init__.py の廃止ではなく、「名前空間パッケージ」という従来とは別物のパッケージです。実際、PEP 420 自身が「通常のパッケージのサポートを削除する意図はない」と明記し、名前空間パッケージの一部にならないと分かっているなら「__init__.py を持つ通常のパッケージの方が性能上の利点がある」とまで書いています。つまり PEP 420 は「不要になった」とは一言も言っていないのです。正確な理解は、Python に2種類のパッケージが存在するようになった、です。
- 通常パッケージ(regular package):
__init__.pyを持つ。従来どおりのパッケージ - 名前空間パッケージ(namespace package):
__init__.pyを持たない。PEP 420 で導入された特殊なパッケージ
__init__.py を「省略」したつもりのディレクトリは、実際には名前空間パッケージという別のモードで動いています。不要になったのではなく、意図せず別の機能を使ってしまっている——これが誤解の正体です。
どちらのモードで動いているかは、2つの属性を見れば判別できます。この判別軸は本記事の全検証で鍵になります。
| 属性 | 通常パッケージ | 名前空間パッケージ |
|---|---|---|
__path__ の型 |
list |
_NamespacePath |
__file__ |
__init__.py の実ファイルパス |
None |
3. 今回のパッケージのディレクトリ構成図
「通知機能を持つ小さなアプリケーション」を想定した検証専用のミニプロジェクト
django_study/ ├── show_sys_path.py ← sys.path を観察するスクリプト(第3章) ├── check_name.py ← __name__ を表示する1行スクリプト(第3章) ├── notifier/ ← 検証用パッケージ本体(第4・5章) │ ├── __init__.py ← 主役。これがあると通常パッケージ │ ├── base.py │ ├── email.py │ └── slack.py ├── plugins/ │ └── notifier/ ← 本体と同名の別ディレクトリ(第5章の統合実験用) │ └── line.py ※ __init__.py は置かない
検証環境: Python 3.14.3(
python3 --versionで確認)
4. sys.path はどう作られるか — __init__.py の検証に入る前に
この章は、後の検証を理解するための準備です。__init__.py の有無が効いてくるのは、import がパッケージを探す場面——その探索の舞台が sys.path というリストです。とくに後で検証する「同名ディレクトリの静かな統合」は sys.path 上に同名のディレクトリが複数あるときに起きるため、このリストがいつ・どう作られるかを先に押さえておくと、検証手順の「なぜ」がすべてつながります。
python3 script.py と打ってから、スクリプトの1行目が実行されるまでに、次の4段階が進みます。

①シェルが実行ファイルを探す。
環境変数 PATH のディレクトリを先頭から順に見て、最初に見つかった python3 を起動します。「パスのリストを順に探し、最初に見つかったものを採用して探索を終える」
このとき、シェルはpython3 script.py のコマンドが打たれたとき、PATH からファイルとしてpython3を探し、script.py は探さない。script.py という文字列を第1引数として python3 に渡します。
②インタプリタ本体の初期化。
組み込み型と一緒に sys モジュールが作られます。sys がインタプリタ自身の状態(探索パスやモジュールキャッシュ)への窓口になれるのは、この最初期に本体と一体で作られる特別なモジュールだからです。なお、import の処理自体は Python で書かれた importlib というモジュールが担っています。ただし importlib自体を import で読み込むことはできません——それを読み込むための import機構がまだ動いていないからです。そこで importlibだけは、ビルド時にインタプリタ本体へ埋め込まれたコピー(frozen module)から、ファイル探索なしで直接読み込まれます
組み込み型(built-in types)とは import なしで最初から使える、Python 本体に組み込まれたデータ型のことです。int、str、list、dict など、
sys モジュールとは Python インタプリタ自身の状態と機能への窓口となるモジュール

③sys.pathの構築。
先頭には実行した .py ファイルと同じディレクトリが入ります。ここでいう「実行した .py ファイル」(=スクリプト)とは、python3 script.py の script.py——引数に渡して直接実行する、プログラムの入口のファイルのことです。基準になるのは「コマンドを打った場所」ではなく「実行したファイルの置き場所」。つまり、どこから実行しても、スクリプトと同じディレクトリに並んでいるモジュールが常に探索対象になります。対比として、引数なしの python3 で REPL(対話モード)を起動した場合の先頭は ''=カレントディレクトリ、つまり「今いる場所」に変わります。続いて環境変数 PYTHONPATH、標準ライブラリの3パスと並びます

show_sys_path.py
"""ファイル実行時の sys.path を観察する。 python3 show_sys_path.py をどのディレクトリから実行しても、 先頭にはスクリプトのあるディレクトリが入ることを確かめる。 """ import sys for p in sys.path: print(repr(p))
自分の環境

- ①スクリプトのディレクトリ + 標準ライブラリ + site の追加 。
- PYTHONPATH の行がない — 自分の環境変数が未設定なので、①と②の間に何も挟まっていません
- 探索の優先順位そのものでもあります。
別の場所から実行しても先頭が変わらないこと

REPL では先頭が '' (カレントディレクトリ)に変わる

この記事がとてもわかり易かったです!
④siteモジュールの自動 import。
仕上げに site-packages が追加されます。venv の検出も .pth ファイルの処理もここ。site-packagesとは pipでインストールしたサードパーティ製パッケージが置かれるディレクトリです。
- venv = ④で参照される site-packages 自体を、プロジェクト専用のものに付け替える
- .pth = ④の site 処理の中で、site-packages に置かれた指示書を読んで sys.path にパスを足す
| 項目 | 標準ライブラリ | サードパーティ製 |
|---|---|---|
| 例 | os, sys, json, datetime |
requests, numpy, django |
| 入手方法 | Python に最初から同梱 | pip install で後から追加 |
| 置き場所 | 標準ライブラリのディレクトリ | site-packages |
| sys.path 追加のタイミング | ③(パス構築) | ④(site が追加) |
手元の Homebrew Python 3.14 で実際に見ると、この4段階の産物がそのまま並んでいます。sys.path を1行ずつ print するだけのスクリプト show_sys_path.py を実行した結果が上のスクリーンショットで、どのディレクトリから実行しても出力は変わりません。
import notifier と書いたとき、Python はこの完成した sys.pathを先頭から走査し、最初に見つかった notifier を採用します——①でシェルが PATH を探索したのと同じ仕組みです。③で見たとおり、ファイル実行なら先頭はスクリプトのディレクトリなので、隣に置いたモジュールは必ず見つかります。
この4段階が済んで初めて、スクリプトが __main__ という名前のモジュールとして実行されます。おなじみの if __name__ == "__main__": が成立するのは、この命名によるものです。(引数なしで起動した場合は、ここでプロンプトが表示されて REPL になります)。
'__main__' はトップレベルコードが実行されるスコープの名前であり、モジュールの __name__ は、標準入力から読まれたとき・スクリプトとして実行されたとき・対話プロンプトから実行されたときに '__main__' に設定される、と定義されています。
![]()
importすると

importされるとトップレベルのコードだけ実行するのでprint("__name__ =", __name__) が実行される
- 直接実行(python3 check_name.py)→ そのファイルは
__main__という名前で実行される - import される(import check_name)→ 本来のモジュール名(check_name)で登録される
Django プロジェクトのコマンド実行の入口である manage.py にも、末尾にこの記述があります。runserver や migrate は、このファイルを通して実行されます。
if __name__=='__main__': main()
最後に視野を広げると、「探索パスのリストを順に見る」仕組み自体は Pythonだけではありません。シェルの PATH、Ruby の $LOAD_PATH、Java のクラスパス——どれも同じ構造で、原則は「最初に見つかったものを採用して探索を終える」です。ところが Python の名前空間パッケージだけは、同名のディレクトリが複数見つかったとき、最初の1件で探索を打ち切らずに全部集めて統合するという特殊なルールで動きます。この例外がどんな症状を生むのか、第5章で実際に観察します。
5. 空の __init__.py は何を宣言しているのか
現場のコードでは、中身が完全に空の __init__.py を大量に見かけます。Django プロジェクトを作れば migrations/__init__.py のような空ファイルが量産されます。あれは無意味なファイルではありません。「存在すること自体」が仕事をしています。
存在するだけで成立する宣言
__init__.py には初期化コードや再エクスポートを書けます。しかしそれらは全部オプションです。一方、「このディレクトリは境界の閉じた通常パッケージである」という宣言は、ファイルが存在するだけで成立します。置いた瞬間に __path__ は list になり、名前空間パッケージのモードから抜けます。
前提: __path __とは何か
パッケージを import すると、Python は「サブモジュールをどこから探すか」のディレクトリ一覧を __path__ という属性に記録します。import notifier.line のように配下を import するとき、Python はこの __path__ に載っているディレクトリの中だけを探します。つまり __path__ =そのパッケージの縄張りのリストです。
notifier/__init__.py
"""notifier パッケージ。 """ print("[init] notifier パッケージを初期化しました")
実例
__init__py がある場合 = 通常パッケージ

__init__.pyが本体として実行された証拠(この print は__init__.pyの中身)- 本体は
module __path__の中身 = 探す場所はnotifier/の1ディレクトリだけ__path__の型は list(固定リスト)__file__= 本体ファイルのパス
__init__py がない場合 = 名前空間パッケージ

- 本体は module
__path__が_NamespacePath= 同名ディレクトリを集めるリストに変わる(今は1つだが、sys.path 上に同名の notifier/ が増えれば追加される)- その型は
_NamespacePath __file__= None(本体ファイルがない)
普通のパッケージ(通常パッケージ)は「1つのディレクトリ=1つのパッケージ」です。それに対して名前空間パッケージは、sys.path 上に散らばっている同名のディレクトリを全部集めて、1つのパッケージに合成するという動きをします。
django_study/ ├── notifier/ ← __init__.py がなければ… │ ├── base.py │ └── email.py └── plugins/ └── notifier/← こっちの「notifier」も └── line.py
sys.path に両方の親ディレクトリが載っていると、Python はこの2つの notifier/ を1つのパッケージとして合成します。だから __path__ が「同名ディレクトリを集めるリスト」(_NamespacePath)になり、notifier.email も notifier.lineも同じ notifier の下に見えてしまいます。
そしてこの宣言は「Python 3.2 以前の名残」ではありません。3.3 以降も「名前空間パッケージにしない」という意思表示として現役の作法です。では、その宣言を省略すると実際に何が起きるのか——次章で確かめます。
6. 省略すると実際に何が起きるか — 手を動かして検証した
検証用に通知アプリ notifier(base.py / email.py / slack.py)を作り、__init__.py を消した状態と足した状態で同じコード・同じコマンドを実行して症状を比較しました。「省略すると困る」と言われる症状が、実際にどう再現するかを見ていきます。
再現した実害
同名ディレクトリが静かに統合される
plugins/notifier/line.py という、本体とは別ディレクトリの同名パッケージを用意し、sys.path に plugins/ を追加した状態で観察します。
__init__.py あり: notifier.__path__ は本体の1ディレクトリのみ。import notifier.line は ImportError。パッケージの境界が閉じています。

sys.path.insert(0, "plugins")でplugins/notifier/line.pyを「Python から見える場所」に置いた上で、それでも入ってこないことを示すためですnotifier.__path__が本体の1ディレクトリだけ。__init__.pyがある通常パッケージは、最初に見つかった時点で境界を確定するからですimport notifier.lineが失敗する。サブモジュールの探索は__path__の中だけで行われるためです
__init__.py なし: notifier.__path__ に2つのディレクトリが入り、別ディレクトリの notifier.line が同じパッケージとして import できてしまいます。

sys.path.insertは同じ操作で、変えたのは__init__.pyの有無だけ。それなのに__path__にplugins/notifierが入ってきましたimport notifier.lineがエラーも警告も出ずに統合されてしまいます。成功時に何も表示されないことこそが証拠です 最後の send("統合された") は、混ざったコードが実際に動くことを示しています。
なにが問題なのか?
意図していないコードが「自分のパッケージとして」実行される
検証で見たとおり、
plugins/notifier/line.pyは誰も「notifier に追加する」と宣言していないのに notifier.line になりました。これが悪意や事故と組み合わさると:- 依存ライブラリやツールが、たまたま自分のパッケージと同名のディレクトリを sys.path 上に置いた
- デプロイ先に古いリリースの残骸ディレクトリが転がっていた
- 作業ディレクトリに実験用の同名フォルダを作ったのを忘れていた
みたいな「知らない場所の .py ファイル」が、
importした瞬間に自分のパッケージの一部として実行されます。トップレベルコードはimport時に走るので、読んだこともないコードが自分のパッケージ名義で動くことになります。環境によって挙動が変わる
統合されるかどうか・何が統合されるかは sys.path の内容次第です。そして sys.path は第3章で見たとおり、実行方法(ファイル実行かREPLか)・実行場所・
PYTHONPATH・venvで変わります。- 手元では動くのに CI では違うモジュールが見える
- 本番だけ
notifier.lineがある
という環境差のバグが生まれます。
衝突が「検知されない」こと自体が問題
両方に
__init__.pyがあれば、片方が消えてModuleNotFoundErrorが出て衝突がエラーとして表面化する。名前空間パッケージは逆で、衝突すると黙って合成する。つまり「同名のものが2つある」という異常事態を、検知する仕組みそのものが失われるのです。両方のパッケージに
__init__.pyがあるとき
touch plugins/notifier/__init__.py — plugins 側にも空の __init__.py を作る。これで両方が通常パッケージになった状態を用意
import sys→ sys.path.insert(0, "plugins") — plugins/ を探索パスの先頭に追加。これで同名の notifier パッケージ候補が2つ(plugins/notifier と本体 notifier/)ある状態になるimport notifier— 探索パスを先頭から走査し、先に見つかった plugins/notifier を採用した時点で探索終了。notifier.__file__→ plugins/notifier/__init__.py — 本体(__file__)がplugins側を指しているnotifier.__path__→ ['plugins/notifier'] の1つだけ — plugins 側のみ。本体のnotifier/はリストに入っていないimport notifier.email→ ModuleNotFoundError — email.py は本体の notifier/ に実在するのに失敗する。サブモジュールの探索は __init__の中だけで行われ、そこに本体側がないため。このエラーこそが「同名パッケージが衝突している」ことを知らせるアラーム
「省略すると壊れる」のではなく、「知らないディレクトリの同名パッケージが、静かに自分のパッケージに混ざりうる」という予測不可能性を抱えることになります。
7. まとめ
検証結果をまとめます。
__init__.pyを省略してもインポートは通る。ただしそれは「不要になった」のではなく、名前空間パッケージという別モードで動いているだけ- 空の
__init__.pyは手抜きではなく、「境界の閉じた通常パッケージである」という宣言を、存在するだけで果たしている
「明示すべき」の根拠は、突き詰めれば予測可能性です。通常のアプリケーションでは、パッケージの境界は閉じているのが正しい状態で、その「ここで境界を閉じる」という宣言が __init__.py です。__init__.py は、Python が規約によって名前空間を区切るための道具です。空の __init__.py 1つが、あなたのパッケージを予測可能に保っています。
8. さいごに
__init__py を深ぼるとpythonのスクリプトの実行、import の仕組み、sys.pathなどいろいろな前提条件を知っていないと完全に理解できないと感じたためAIが当たり前の今でも基礎が大事と感じさせられました。
この記事を読んで興味を持って下さった方がいらっしゃればカジュアルにお話させていただきたく、是非ご応募をお願いします!
iimon採用サイト / Wantedly / Green