skip to content

Your team ships an internal chat-transcript archiver library and other services now import helpers you never meant to expose. How do you decide what the public API promises, and how do you version those promises?

level: principalimportance: should knowfreq 30%

answer

  1. The exported surface is a budget
  2. Private until deliberately promoted
  3. Agree what counts as a break
  4. One documented import path
  5. Ask why callers went around you

basics

~20 s

Make the surface small and explicit — underscore internals, list the promised names in __all__ at one import path — then treat every listed name as a versioned obligation. Where callers reached into internals, ask what the public API failed to offer before policing them.

solid answer

~50 s

Start from the position that everything is private until deliberately promoted: implementation modules behind underscore names, a single documented import path, and an `__all__` in the package root that is exactly the list you are willing to keep. Then define what a break means for a Python library specifically — renaming a keyword parameter, changing a default, moving a name to another module, raising a different exception type, dropping a Python version — and bind those to the version number the installer reads, with deprecation cycles for anything on the list. Where other services already import `_internals`, treat it as a design signal first: they usually reached past the public API because it did not offer what they needed. Either promote and support a proper replacement or fix the gap, then remove the private usage. The policy also needs teeth: usage evidence before removals, and a support window teams can plan against.

go deeper

for a junior

Focus on the habit rather than the policy: keep helpers underscored, list what you intend to be public in __all__, and do not import another package's underscore names into your own code.

for a middle

Be able to argue why a small exported surface is easier to maintain, and name several changes that break callers even though the code still runs — a renamed keyword parameter or a changed default value, for instance.

for a senior

Show the operating side: measuring who imports what, running deprecation cycles, keeping one documented import path, and closing the API gap that made someone reach into internals in the first place.

for a principal

Own the written compatibility policy, the versioning scheme and the support window, and defend the tradeoff between carrying shims and breaking consuming teams — including when adopting a private name is cheaper than migrating everyone off it.

## The surface is a liability you choose to take on Every name a library exports is a promise someone will build on and you will have to keep, deprecate or break. That makes surface area a budget, not a byproduct. The default posture for a shared library is private-until-promoted: implementation lives in underscore-named modules, the package root imports the handful of names you intend to support, and `__all__` lists exactly those. Promotion is a deliberate review decision, because you are signing up to version the name from then on. A second, easily-missed part of the promise is the *import path*. If callers can reach the same class as `archive.Archive`, `archive._core.Archive` and `archive.core.Archive`, you have three promises for one object, and moving the implementation breaks two of them. Publish one path, document it, and keep the rest underscored. ## What counts as a breaking change in Python The version number is only useful if everyone agrees what a break is, and Python's dynamism makes the list longer than newcomers expect. All of these break callers even though the code still "works": * Renaming a parameter — callers pass it by keyword. * Changing a default value, or making an optional parameter required. * Moving a name to another module, or removing it from `__all__`. * Raising a different exception type, or a new one, from an existing call. * Narrowing accepted input types, or widening returned types in a way annotations reveal. * Dropping support for a Python version, or for an operating system or architecture you had wheels for. * Changing the semantics of a value while keeping its shape — the change nobody's type checker catches and everybody's tests miss. Write that list down in a compatibility policy, because a team that has not agreed on it will ship all seven as patch releases. Note also what is explicitly *not* covered: anything underscored, anything reached through a non-documented path, and behaviour you never specified. Saying what you do not promise is as valuable as saying what you do. ## Tying the promise to the number the installer reads The published version is the machine-readable form of the policy: consumers pin against it and resolvers act on it. Make it single-sourced so the number in the distribution metadata cannot drift from the source tree, and make the release process, not a person, own the bump. Then choose a scheme and mean it. A `0.x` line is a legitimate way to say "no compatibility promise yet", provided you leave it before other teams depend on you. A date-based scheme suits a library whose changes are continuous and whose consumers upgrade in lockstep, but it gives resolvers no signal, so it fits an internal monorepo far better than a published package. Whatever the scheme, the version is worthless without a support window. State how long a deprecated name lives and how many releases back you will patch, so consuming teams can plan rather than react. ## The helpers that got imported anyway Take the concrete case seriously rather than as a discipline problem. Suppose one service's archiving job holds a 2.4 GB working set because the public `load_transcript()` returns the whole transcript, so its authors went to `_stream_chunks` instead. The convention worked exactly as designed — it told them they were on their own — but the pull was real. Policing the boundary without closing the gap just moves the workaround somewhere worse. The sequence that works: measure who imports what, and why; fix the API gap by promoting a supported streaming entry point; migrate the callers; then remove or rename the private helper once nothing points at it. Add an automated check so new private imports fail review rather than accumulating. Where a private name has become so widely used that migration would cost more than supporting it, the honest move is to adopt it — promote it, document it, and take on the versioning obligation — not to leave it underscored and pretend the promise does not exist. ## Where the boundary sits for an internal library Being internal changes the economics, not the principles. You can see every caller, so you can remove things faster and with real evidence, and you may be able to land the migration in the consumers yourself. What you cannot do is skip the announcement: an unannounced break in a shared library costs several teams a day each, and that cost is invisible to the team that saved an hour by not writing a deprecation shim. The deal to strike is a small surface, a clear compatibility policy, an honest version number, and a support window short enough that you are not carrying shims forever but long enough that nobody's release is hostage to yours.

  • Another team's service already depends on a private helper. Do you break it or adopt it?
    Decide on evidence. If one caller needs it and the public API can be extended to serve them, promote a proper entry point, migrate the caller and delete the helper. If it is widely used and migration would cost more than support, adopt it: rename it public, document it, test it and version it. What you should not do is leave it private while behaving as though it were supported — that is an unversioned promise you will break by accident.
  • How do you stop the private boundary eroding once the library has many consumers?
    Make the violation fail rather than be noticed. A lint rule or import-graph check in the consumers' pipelines that rejects importing an underscore-prefixed name from another distribution catches new cases at review time, and a periodic scan of the codebase quantifies the existing ones. Pair that with a review gate on additions to `__all__`, since every accepted name is a permanent obligation.
  • When is a date-based version scheme a defensible choice for a Python library?
    When compatibility signalling is not the version's job: an internal library whose consumers all upgrade in lockstep from one repository, or a product-like distribution whose users care when it shipped rather than what it broke. Its weakness is that resolvers and pinned ranges learn nothing from the number, so a published library with independent consumers is usually better served by a scheme where a bump communicates breakage.
  • What belongs in a compatibility policy beyond 'we follow semantic versioning'?
    The Python-specific list of what counts as a break — keyword-parameter renames, changed defaults, moved import paths, new or changed exception types, dropped interpreter versions — plus what is explicitly not covered: underscored names, undocumented import paths and unspecified behaviour. Add the support window: how long deprecated names live and how far back you patch. Without those specifics, teams disagree release by release.

saying these in an interview costs you the question

  • Treats every module-level name as public by default
  • Blames consumers instead of asking what the API lacked
  • Cannot say what counts as a breaking change in Python
  • Promises compatibility with no stated support window
  • Removes names without evidence of who still calls them
  • Ships several documented import paths for one object

context