How I Organize Python Libraries
When building Python libraries, structure matters just as much as syntax. A clean, consistent layout helps make one’s codebase understandable not just for others, but for one’s future self as well.
Over the years, I’ve gravitated towards a few guidelines when organizing my own libraries…
Table of Contents
- What goes in a Python Library?
- What goes in the /lib package?
- What goes in the /core package?
- What goes in the /util package?
- What goes in the other packages?
- Final Thoughts
What goes in a Python Library?
Before diving in, let’s quickly define a few terms the Python community often throws around:
- A module is a single
.pyfile that contains Python code. - A package is a directory containing an
__init__.pyfile and one or more modules or sub-packages. - A library is a collection of packages and modules that provide a wide range of tools.
The tree of a Python library could be exemplified as follows:
library/
├── __init__.py
├── package_0
│ ├── __init__.py
│ ├── module_0.py
│ ├── module_n.py
│ ├── sub_package_0
│ │ ├── __init__.py
│ │ ├── module_0.py
│ │ └── module_n.py
│ └── sub_package_n
│ └── __init__.py
└── package_n
└── __init__.py
With that context, here’s how I like to break things down when building a Python library.
What goes in the /lib package?
The /lib package is where I place customizations and extensions to third-party libraries. It’s essentially my override zone. Anything in /lib is tightly coupled to an external dependency.
-
In my
pyswark-lib, for example, I extend thePydantic BaseModelwith extra features I use consistently across thepyswark.from pyswark.lib.pydantic.base import BaseModel -
Likewise, AliasEnum is my custom flavor of Python enums with support for aliases.
from pyswark.lib.aenum import AliasEnum -
If another developer were to extend
pyswark, they might create their own/lib/pyswarkdirectory to patch or override the internals.
Guideline: modules in
/libcan be imported from anywhere in the library.
What goes in the /core package?
This is the heart of the library, containing modules that form its foundational internal frameworks. These aren’t just utility functions. They’re the building blocks that define how the library operates and approaches problems.
-
In
pyswark, for example, I’ve added a flexible I/O system used across the library. It’s abstracted enough to support local files, URLs, or custom URIs.from pyswark.core.io import api data = api.read( "pyswark://data/df.csv" )
Guideline: modules in
/coreshould not be imported by anything in/libor/util.
What goes in the /util package?
The /util package is where I stash all those low-level helpers that don’t fit anywhere else. They’re generic, lightweight, and not tied to any specific functionality or domain model.
-
Common contents include:
-
Custom loggers
-
Timing/performance decorators
-
Memoization helpers
-
-
Some might argue that the previously mentioned
pyswarkI/O system belongs in/util, but I placed them in/corebecause they act as a framework—not just a set of helpers. It’s a judgment call, but that’s my reasoning.
Guideline: modules in
/utilshould not be imported by anything in/libor/core
What goes in the other packages?
Outside of /lib, /core, and /util, all other packages should reflect features or domains specific to the library.
-
These contain the sub-packages and modules that users will interact with most directly.
-
The other packages can be imported by eachother, so long as circular dependencies are avoided.
-
A clean separation of concerns here goes a long way toward making the project scalable and testable.
Final Thoughts
Every library has its own needs, and no one structure fits all.
But a few conscious architectural choices – like separating patches, core logic, and utility helpers – help to avoid headaches down the road.
If you’re building your own library, I hope this post offers a solid starting point.