How-tos
Code That Doesn't Crumble: Structuring Large Python Projects That Actually Scale
Learn how to scale a Python codebase beyond single-file scripts with domain-driven folder patterns, layered architecture, and robust configuration. Structure your project so it stays maintainable and testable at 10,000 lines or more.
June 2026 · 8 min read · 2 views · 0 hearts
Advertisement
Code That Doesn't Crumble: Structuring Large Python Projects That Actually Scale
You've outgrown the single-file script. Your main.py is now 2,000 lines, you're scared to touch anything, and "refactoring" feels like a threat from your past self. Welcome to the messy adolescence of every Python project.
The good news? Scaling a Python codebase doesn't require switching to Go. It requires structure. Here's how to architect a project that stays sane at 10,000 lines — and beyond.
The Flat Is a Trap
The biggest mistake in growing Python projects? Everything in one directory.
# Bad
project/
main.py
helpers.py
models.py
utils.py
config.py
This flat structure works for prototypes. For a real application? It's chaos. Every file depends on every other file. Changing utils.py breaks half the codebase. You can't tell what the project does without reading everything.
The Domain-Driven Folder Pattern
Instead of organizing by file type (models, services, utils), organize by feature or domain. This is the single most impactful change you can make.
# Good
project/
config/
settings.py
logging.py
apps/
users/
models.py
services.py
tests/
billing/
models.py
services.py
tests/
notifications/
email.py
sms.py
core/
database.py
exceptions.py
tests/
conftest.py
integration/
Now when you need to change user billing logic, you know exactly where to look. Each app is semi-independent. You can safely refactor billing/ without touching users/.
Don't Fight Circular Imports — Design Around Them
Circular imports are the #1 pain point in medium-to-large Python projects. They happen when module A imports module B, and B imports A (directly or through a chain).
Why it happens: You put type hints, model definitions, and business logic all in the same files.
The fix: Use a layered architecture.
# Layer 1: Pure data (no imports from your project)
# models.py
@dataclass
class User:
id: int
name: str
# Layer 2: Business logic (imports from Layer 1 only)
# services.py
from models import User
def create_user(name: str) -> User:
return User(id=random_id(), name=name)
# Layer 3: Presentation/API (imports from Layers 1 and 2)
# api.py
from models import User
from services import create_user
If a file starts importing from a "higher" layer, you've created a potential circular import. Refactor.
One more trick: Deferred imports inside functions. Not ideal, but saves you when third-party packages force your hand.
def get_expensive_model():
from app.core.ml_model import Predictor # import only when called
return Predictor()
Configuration That Doesn't Leak
Hardcoding environment names, database URLs, or API keys inside business logic is a fast path to deployment disasters.
The right approach: A single source of truth for configuration, loaded early, used everywhere.
# config/settings.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
debug: bool = False
secret_key: str
allowed_hosts: list[str] = ["*"]
class Config:
env_file = ".env"
settings = Settings()
Then in any module:
from config.settings import settings
engine = create_engine(settings.database_url)
Now changing environments means swapping .env files, not rewriting imports.
The Path That Works Every Time
For shared utilities and imports, Python's default behavior is fragile. If you run a script from project/apps/users/tests/test_services.py, your imports break unless you've set up paths correctly.
Always use absolute imports relative to your project root:
# Correct — works from anywhere
from app.users.services import create_user
# Wrong — breaks depending on cwd
from ..users.services import create_user # fragile relative import
For runnable scripts, use a __main__.py and run with the module flag:
python -m app.users.run_migration # always works
Tests That Don't Fight You
Tests are not an afterthought — they're the canary in the coal mine for your project structure. If writing a test requires 20 lines of setup imports, your structure is wrong.
Convention the project root:
# tests/conftest.py
import sys
from pathlib import Path
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent.parent))
One test file per module:
tests/
conftest.py
unit/
users/
test_services.py
test_models.py
billing/
test_services.py
integration/
test_user_billing_flow.py
Now each test file mirrors the source code. You can tell at a glance what's tested and what isn't.
The One Rule That Binds Them
Every structural decision in a large Python project comes down to one question: If I change this file, how many other files break?
- Flat structure? Everything breaks.
- Circular imports? Things break at runtime, silently.
- Hardcoded config? Everything breaks when you deploy.
- Relative imports? They break when you move a file.
The answer is always: minimize coupling. Group by domain, layer your imports, centralize config, and test in isolation. The code that follows these rules doesn't just scale — it survives long enough for you to hate it for different reasons.
Advertisement
Comments
Questions, corrections, and tips stay visible for everyone reading this page.
Join the discussion
No comments yet
Be the first to leave a note — it helps the next reader.