Maintenance

Site is under maintenance — quizzes are still available.

Go to quizzes
Sponsored Reserved space — layout preview until AdSense is connected

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

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.

Comments

Questions, corrections, and tips stay visible for everyone reading this page.

0 in thread

Join the discussion

Shown next to your comment.

Up to 4,000 characters

No comments yet

Be the first to leave a note — it helps the next reader.