What is PEP 829 (Package Startup Configuration Files)?
PEP 829: Package Startup Configuration Files splits the two jobs that .pth files do into two separate files. .pth keeps the sys.path extension job. A new <name>.start file takes over package initialization, replacing the import line that turns every .pth file into an exec() surface at interpreter startup. Authored by Barry Warsaw, accepted on 24 April 2026, and now Final, the PEP is implemented in Python 3.15.
Separate the two jobs .pth files do
A .pth file in site-packages does two unrelated things. Lines that name a directory get added to sys.path, which is the benign use that editable installs and namespace packages rely on. Lines that begin with the word import are passed to exec() at every interpreter startup. Both jobs travel in the same file, with no schema and no boundary between them.
The PEP is direct about why that matters: “import lines are executed using exec() during interpreter startup, which opens a broad attack surface.” The March 2026 litellm compromise weaponized exactly this line type; why installing a Python package can run code covers that incident and the other three execution surfaces.
PEP 829 does not remove the surface from existing Python releases, but it ends its future. Python 3.15 introduces the replacement, and three releases later the old mechanism stops running.
Define the .start file format
A <name>.start file lives next to .pth files in site-packages. Each line is one entry point, written in the colon form that pkgutil.resolve_name() accepts, for example mypkg.startup:initialize. The interpreter resolves each name and calls it with no arguments. The two-file split is intentional: .start files cannot extend sys.path, and .pth files no longer need an import line for any new use case.
The <name> prefix is arbitrary and need not match the package, though the PEP recommends matching for clarity. Files are encoded as UTF-8 with an optional byte-order mark (the utf-8-sig codec). Lines beginning with # are comments. Within a site-packages directory the interpreter sorts .start files alphabetically by filename and runs every entry point in order; entries are not de-duplicated, so a callable listed twice runs twice.
Errors are reported at two levels. A file that cannot be read (missing, hidden, or not valid UTF-8) is skipped, and the reason appears only when Python runs with -v. Once a file is read, every problem with an entry point prints to stderr and processing continues with the next entry: Invalid entry point syntax in <file> for a line without a colon, Error resolving entry point <name> from <file> when the module or attribute cannot be imported, and Error in entry point <name> from <file> with a traceback when the callable raises.
The order of work at startup is: collect every .pth file, apply all sys.path extensions, then collect every .start file and run its entry points. All path changes are visible by the time any callable runs, so a .start entry point can import modules from a sibling package’s .pth-extended path.
Try a .start file on Python 3.15
The whole mechanism fits in two files. Create a Python 3.15 environment with uv, drop a module and a .start file into its site-packages, and run any Python command (macOS/Linux shell):
uv venv --python 3.15
SITE=$(uv run --no-project python -c "import sysconfig; print(sysconfig.get_paths()['purelib'])")
cat > "$SITE/hello_start.py" <<'EOF'
import sys
def init():
sys.stderr.write("hello_start.init ran\n")
EOF
echo "hello_start:init" > "$SITE/hello_start.start"
uv run --python 3.15 --no-project python -c "print('hi')"The entry point runs before the script does:
hello_start.init ran
hi
Nothing imported hello_start; the .start file alone made it run. Passing -S skips site processing and prints only hi. If init() raises, Python reports the failure and keeps going:
Error in entry point hello_start:init from .venv/lib/python3.15/site-packages/hello_start.start
Traceback (most recent call last):
File "<frozen site>", line 566, in _execute_start_entrypoints
File ".venv/lib/python3.15/site-packages/hello_start.py", line 5, in init
raise RuntimeError("boom")
RuntimeError: boom
hi
Walk the deprecation timeline
PEP 829 retires the .pth import line in three phases.
- Python 3.15 through 3.17. Both file formats are supported. A
<name>.pthfile’simportlines are still executed, unless a matching<name>.startfile exists in the same directory. When the names match, the.startfile shadows the.pthimport lines and only the.startentry points run. The.pthfile’s directory lines still extendsys.patheither way. Running Python with-vshows which case applied:import lines in <name>.pth are deprecated, use entry points in a <name>.start file instead.when they ran, orimport lines in <name>.pth are suppressed due to matching <name>.start file.when they did not. - Python 3.18 and 3.19. Import lines in
.pthfiles are silently ignored. A package that still ships only a.pthfile withimportlines stops running its initialization code, with no error and no warning. - Python 3.20 and later. Python emits a warning whenever it sees an
importline in a.pthfile. The warning runs in addition to the silent ignore.
A .pth file that contains only sys.path directory lines is unaffected throughout. The deprecation targets import lines specifically, which is the line type that gets passed to exec().
Migrate as a package author
Most packages do not ship .pth files at all. The two common cases that do are editable installs (the editable-wheel hook that PEP 660 defines) and tools that need to run a bit of setup code at every interpreter startup. A src-layout editable install writes a bare path line and is unaffected. A flat-layout editable install under setuptools writes an import line that installs a module finder (__editable__.<name>.pth), so backends that use that strategy must ship a .start file before the deprecation ends. The second case, startup code, is the one PEP 829 was written for.
A package that needs initialization to run on every Python startup, and that needs to support both pre-3.15 and 3.15+ Pythons, ships both files:
- A
<name>.pthwhoseimportlines run on Pythons that predate PEP 829. - A
<name>.startwhose entry points run on Python 3.15 and later.
On 3.15 through 3.17 the matching <name>.start shadows the <name>.pth import lines, so the callable runs once per startup, not twice. On 3.18 and later the .pth import lines are ignored anyway, so only the .start entry points run. A package that drops support for Pythons predating 3.15 can ship only the .start file.
The migration also forces a small refactor: anything the .pth import line used to do has to live behind a real callable somewhere in the package, which is the point. An entry point referenced by a callable name is auditable and shows up in static analysis; an exec() of an import statement does not.
Defend before 3.18 lands
Until Python 3.18 reaches the platforms a project actually runs on, a malicious release can still ship a .pth file that the interpreter executes, and PEP 829 leaves the other three execution surfaces untouched on every Python. The defenses in the security explainer still apply.