Python 3.15 .start Files: Replace Executable .pth Hooks Safely

Python 3.15 .start Files: Replace Executable .pth Hooks Safely Python

Python 3.15 introduces .start files as the explicit replacement for executable import lines in .pth files. If your package runs initialization code during interpreter startup, move that code into a zero-argument callable and reference it as package.module:callable from a matching .start file.

The change is defined by PEP 829 and is available in Python 3.15. It does not remove the useful path-extension role of .pth files. Instead, it separates path configuration from startup code so that startup behavior is easier to audit, test, and eventually control.

This guide shows the migration pattern, the compatibility strategy for older Python releases, and the checks package maintainers should add before Python 3.15 reaches its final release.

Why Python Is Replacing Executable .pth Lines

A .pth file traditionally has two unrelated jobs:

  • add directories to sys.path;
  • execute any line that begins with import during interpreter startup.

The second behavior is powerful but opaque. A package can place multiple statements after an import by separating them with semicolons, and site.py passes that source to exec(). The behavior occurs before the user's first line of Python code, often without an obvious entry point to inspect.

PEP 829 keeps ordinary path entries in .pth files, but moves startup code to a dedicated format:

package.module:callable

Python resolves that reference with pkgutil.resolve_name(..., strict=True) and calls it with no arguments. The callable's return value is ignored.

This is a narrower and more reviewable mechanism, but it is not a security sandbox. A startup callable still runs Python code with the process's permissions. Treat every installed .start file as executable configuration.

The Minimal Migration

Assume a package currently installs this file in site-packages:

acme_bootstrap.pth:

import acme_bootstrap; acme_bootstrap.initialize()

Keep the initialization logic in an importable package module and make the entry function accept no arguments:

acme_bootstrap/__init__.py:

import os


def initialize() -> None:
    os.environ.setdefault("ACME_RUNTIME", "enabled")

Then install a matching startup file beside the existing .pth file:

acme_bootstrap.start:

acme_bootstrap:initialize

The matching base name matters. When Python 3.15 finds both acme_bootstrap.pth and acme_bootstrap.start, it ignores executable import lines in that .pth file and runs the entry points from the .start file instead. Ordinary path lines in the .pth file still work.

Both files belong in a directory that the site module treats as a site-packages directory. A .start file buried inside the package directory will not be discovered as startup configuration.

Support Older and Newer Python Versions Together

Many packages cannot require Python 3.15 immediately. PEP 829 provides a compatibility pattern for the transition period:

acme_bootstrap.pth:

import acme_bootstrap; acme_bootstrap.initialize()

acme_bootstrap.start:

acme_bootstrap:initialize

On Python 3.14 and earlier, the interpreter does not know about .start files, so it executes the legacy .pth import line. On Python 3.15 and newer, the matching .start file takes precedence and suppresses that import line. The same callable is used in both paths, which keeps behavior aligned while avoiding two separate implementations.

Do not change the two files to different base names during this period. For example, legacy_bootstrap.pth and acme_bootstrap.start do not match, so Python 3.15 can run both and initialize the package twice.

The current removal timeline is deliberately gradual:

  • Python 3.15 through 3.17 continue to process legacy import lines when no matching .start file exists.
  • Python 3.18 and 3.19 ignore those import lines.
  • Python 3.20 reports their presence as a warning.

Path-only lines in .pth files are not being removed by PEP 829.

Verify the Startup Hook in an Isolated Environment

Test the built artifact, not only the source tree. Create a clean virtual environment with Python 3.15, install the wheel, and inspect the resulting site-packages directory:

py -3.15 -m venv .venv
.venv\Scripts\python -m pip install --upgrade pip
.venv\Scripts\python -m pip install .\dist\your_package.whl
.venv\Scripts\python -c "import sys; print(sys.version)"

Your test should prove three things:

  1. the .start file was installed at the site-packages root;
  2. its target resolves to a callable that accepts zero arguments;
  3. startup happens exactly once when a matching legacy .pth file is present.

For a side-effect-free test, let the startup callable increment an in-memory counter exposed by the package, append one line to a temporary file, or register a test-only audit event. Avoid tests that mutate user-wide configuration or depend on network access during interpreter startup.

The following reduced test mirrors the PEP 829 transition behavior:

from pathlib import Path
import site

site_dir = Path("demo-site")
state = site.StartupState()
state.addsitedir(str(site_dir))
state.process()

I ran this pattern on the official 64-bit Windows build of CPython 3.15.0rc2. The isolated directory contained a matching .pth import line and .start entry point. The .start callable created its marker, while the legacy .pth import marker remained absent. That confirms the precedence behavior without treating the result as a performance benchmark.

Understand Ordering and Failure Behavior

Python processes startup configuration in phases:

  1. it reads .pth files and collects valid path extensions;
  2. it adds those paths to sys.path;
  3. it reads .start files;
  4. it invokes entry points and any remaining legacy import lines.

Applying all path extensions first means a startup callable can live on a path introduced by a .pth file. File names are processed in alphabetical order, but duplicate startup entry points are not deduplicated. If the same callable appears twice, Python calls it twice.

Design startup code to be idempotent even when you believe the packaging layout is unique. Editable installs, layered environments, and duplicated files can expose assumptions that a clean wheel test misses.

If Python cannot resolve or invoke an entry point, it prints the error to stderr and continues with the remaining entries. That makes a clear diagnostic essential. Keep the callable small, avoid swallowing exceptions, and move expensive work behind a normal application entry point whenever startup execution is not strictly required.

Using python -S disables site processing, including .pth and .start files. A program that requires the startup hook should either document that constraint or provide an explicit initialization API as a fallback.

Common Migration Failures

The Callable Requires an Argument

This will fail because .start entry points receive no arguments:

def initialize(config_path: str) -> None:
    ...

Use a zero-argument adapter and read configuration through a documented mechanism:

def initialize() -> None:
    config_path = os.environ.get("ACME_CONFIG")
    configure(config_path)

The Reference Omits the Callable

Python 3.15 requires the strict colon form. acme_bootstrap is not enough; use acme_bootstrap:initialize or a fully qualified reference such as acme_bootstrap.startup:initialize.

The File Uses the Wrong Encoding

Write .start files as UTF-8. PEP 829 permits UTF-8 with an optional byte-order mark (utf-8-sig). Do not rely on the machine's locale encoding.

Startup Performs Expensive Work

Every normal interpreter launch processes these entry points. Network requests, large imports, database connections, or filesystem scans can slow every CLI command and worker start. Use .start only for work that must happen before user code, and measure its cost in a realistic environment.

Release Checklist for Package Maintainers

Before shipping the migration:

  • confirm the built wheel installs <name>.start at the site-packages root;
  • keep only path-extension lines in .pth when older-Python compatibility is no longer needed;
  • use identical base names while .pth and .start coexist;
  • make every startup callable zero-argument, small, deterministic, and idempotent;
  • test Python 3.14 and Python 3.15 in separate clean environments;
  • run a verbose Python startup during CI to catch parsing and resolution diagnostics;
  • test with python -S if your application supports site-disabled execution;
  • document why startup execution is necessary and how users can disable or replace it.

Python 3.15.0rc2 was released on September 1, 2026, and the final 3.15.0 release is scheduled for October 1, 2026. Release candidates are intended for compatibility testing rather than production deployment. This is the right window to inspect built wheels, exercise startup paths, and report regressions before the final release.

Conclusion

Python 3.15 .start files make package startup hooks explicit: keep path configuration in .pth, put startup behavior in a zero-argument callable, and reference it with package.module:callable. Packages supporting older Python versions can ship matching .pth and .start files temporarily, then remove the executable .pth line after the compatibility window.

The migration is small, but the validation should be deliberate. Test the installed wheel, prove that initialization occurs once, and keep startup code minimal and auditable.

Official References

Comment

Copied title and URL