Skip to main content

Technical Debt: How to Measure and When to Pay

Константин Потапов
18 min

Debt is not bad code. It is the gap between the current architecture and the one that solves current problems. Metrics, refactoring ROI, and cases where you should leave the code alone.

Technical Debt: How to Measure and When to Pay

One CTO says: "Refactor the whole project, this is debt." The other: "Don't touch what works. Features."

The first team spent three months polishing auth. Tests green, architecture like a textbook. While they polished, competitors shipped two features. The business counted $400k of opportunity cost.

The second left "garbage" alone for two years. A feature started taking three times longer. Bugs quadrupled. A year went into a rewrite.

The question is not whether to refactor. The question is what, when, and for how much.

Fifteen years of watching debt kill a product. On forty-plus projects it is the same: people treat debt as morality. It is arithmetic.

What counts as debt

Definition

Technical debt is the gap between the current architecture and the one that solves current problems. Not future. Not hypothetical.

Like a loan:

  • You borrow: faster to production
  • You pay interest: each new feature costs more
  • Default: rewrite from scratch
  • Refinance: refactoring

Metaphor from Ward Cunningham (coined the term, 1992):

"Shipping first-time code is like going into debt. A little debt speeds development so long as it is paid back promptly with a rewrite... The danger occurs when the debt is not repaid. Every minute spent on not-quite-right code counts as interest on that debt."

Kinds of debt

Not all debt is equal. There's a mortgage, and there's a loan for an iPhone. Let's break it down.

1. Deliberate Debt

What: Conscious decision to write "quick and dirty" for speed.

Example:

# TODO: Hardcoded for now, move to config later
PAYMENT_API_URL = "https://api.stripe.com/v1"
 
def process_payment(amount):
    # Temporary workaround for MVP
    # After MVP: add retry logic, fallback, monitoring
    response = requests.post(PAYMENT_API_URL, data={"amount": amount})
    return response.json()

When justified:

  • MVP to validate hypothesis
  • Critical deadline (conference, exhibition, investor demo)
  • Feature needed to measure metrics (A/B test)

Risks:

  • TODO becomes NEVER
  • "Temporary" workaround lives for years
  • Interest accumulates unnoticed

2. Inadvertent Debt

What: Team didn't know a better solution. Naive implementation.

Example:

# Junior thought this was normal
def get_all_users():
    users = []
    for user_id in range(1, 100000): # Oh my...
        user = db.query(f"SELECT * FROM users WHERE id={user_id}")
        if user:
            users.append(user)
    return users
 
# Production a month later: 503 Server Timeout

How it happens:

  • Junior developers without mentorship
  • No code review
  • Team doesn't know patterns for this task

How to avoid:

  • Code review mandatory
  • Pair programming for complex tasks
  • Team training on best practices

3. Bit Rot

What: Code was good a year ago. But libraries updated, patterns changed.

Example:

# 2020: this was trendy
class UserViewSet(viewsets.ModelViewSet):
    queryset = User.objects.all()
    serializer_class = UserSerializer
    permission_classes = [IsAuthenticated]
 
# 2025: nobody does this anymore
# There's async views, Pydantic validation, rate limiting out of the box

Causes:

  • Dependencies outdated
  • Best practices evolved
  • New frameworks appeared

When to touch:

  • Security vulnerabilities
  • Blocks new features
  • Prevents scaling

4. Architectural Debt

What: Wrong architecture at system level.

Example:

# Initial architecture (MVP)
Monolith Django App
  ↓
PostgreSQL

# Reality after 2 years (100k+ users)
Monolith (5M lines of code)
  ↓
PostgreSQL (1TB, slowing down)

# Needed architecture
API Gateway
  ↓
├─ User Service (microservice)
├─ Payment Service (microservice)
├─ Notification Service (microservice)
  ↓
PostgreSQL + Redis + Kafka

When critical:

  • Team grew from 3 to 30 people
  • Deploy takes 2 hours
  • Can't scale horizontally

Metrics

You can't manage what you don't measure. Here are metrics I use on real projects.

1. Code Churn Rate

What it measures: How often a file changes. High churn = high debt.

Formula:

Code Churn = Number of file changes per period / Total files

How to measure:

# Git: top-10 most changed files in 6 months
git log --since="6 months ago" --pretty=format: --name-only \
  | sort | uniq -c | sort -rg | head -10

Example output:

245 src/models/user.py        # ← Clear problems here
189 src/api/payments.py       # ← Here too
 87 src/utils/validators.py
 56 src/views/dashboard.py

Interpretation:

  • Churn > 100: File rewritten every week. Code unstable.
  • Churn 50-100: High activity. Debt may be accumulating.
  • Churn < 50: Stable code.

Actions:

# If user.py changes 245 times in 6 months:
# 1. Code review: why so many changes?
# 2. Refactoring: class might be too large (God Object)
# 3. Split: User → UserProfile, UserAuth, UserSettings

2. Cyclomatic Complexity

What it measures: Number of independent execution paths in code.

Formula:

Complexity = Edges - Nodes + 2 (for control flow graph)

In simple terms: Number of if, for, while, and, or in a function.

Tool:

# Python: radon
pip install radon
radon cc -a src/ --total-average
 
# Output:
# src/models/user.py
#   M 245:0 UserModel.validate - D (23)  # ← Complexity 23 is bad
#   M 112:0 UserModel.save - C (15)

IDE with built-in support:

Modern IDEs show Cyclomatic Complexity right in the editor:

  • PyCharm: shows complexity with highlighting (gray = ok, yellow = attention, red = problem)
  • VS Code: via "Python Complexity" or "SonarLint" extension
  • IntelliJ IDEA: built-in code analysis with metrics
  • Visual Studio: Code Metrics (Analyze → Calculate Code Metrics)

Example in PyCharm:

def process_payment(...): # ← PyCharm shows: Complexity: 23 (gray hint)
    # Hover to see: "Cyclomatic complexity too high"

IDE advantages:

  • See metrics while writing code (not post-factum)
  • Configure thresholds (warning at > 10, error at > 20)
  • No need to run separate commands

Interpretation (Software Engineering Institute standard):

  • 1-10: Simple code, easy to test
  • 11-20: Medium complexity, bug risk growing
  • 21-50: High risk, code unclear
  • 50+: Untestable code, refactoring mandatory

Bad code example:

def process_payment(user, amount, currency, method, promo_code=None):
    if user.is_active:
        if user.balance >= amount:
            if currency in ["USD", "EUR", "RUB"]:
                if method == "card":
                    if user.card_verified:
                        if promo_code:
                            discount = get_discount(promo_code)
                            if discount:
                                amount = amount * (1 - discount)
                        # ... 50 more lines of nested ifs
                        return True
    return False
 
# Complexity: 42 🤯

Refactored:

def process_payment(user, amount, currency, method, promo_code=None):
    _validate_user(user)
    _validate_currency(currency)
    _validate_payment_method(user, method)
 
    final_amount = _apply_discount(amount, promo_code)
    return _charge_user(user, final_amount, method)
 
# Complexity of each function: 3-5 ✅

3. Test Coverage

What it measures: % of code covered by tests.

Formula:

Coverage = (Lines executed in tests / Total lines of code) × 100%

Tool:

# Python: pytest-cov
pytest --cov=src --cov-report=html
 
# Output:
# Name                Stmts   Miss  Cover
# ---------------------------------------
# src/models/user.py    245     89    64%  # ← Low coverage
# src/api/auth.py       156      5    97%  # ← Good

Interpretation:

  • 80-100%: Good coverage (not a guarantee, but an indicator)
  • 50-80%: Medium debt, regression risk
  • < 50%: High debt, every change is Russian roulette

Important: 100% coverage ≠ 0% bugs. But 0% coverage = 100% pain when refactoring.

Real-life example:

# Code without tests (coverage 0%)
def calculate_discount(user, cart_total):
    if user.is_vip and cart_total > 1000:
        return cart_total * 0.2
    elif user.orders_count > 10:
        return cart_total * 0.1
    return 0
 
# Junior changed logic:
def calculate_discount(user, cart_total):
    if user.is_vip or cart_total > 1000: # ← Error: 'and' → 'or'
        return cart_total * 0.2
    # ...
 
# Result: VIP client got 20% discount on $50 cart
# Business lost $10k over the weekend

With tests:

def test_vip_discount():
    user = User(is_vip=True)
    assert calculate_discount(user, 1500) == 300  # ❌ Test failed
    # Junior saw error BEFORE production

4. Code Duplication

What it measures: % of repeated code blocks.

Tool:

# jscpd (works with Python, JS, etc.)
npm install -g jscpd
jscpd src/
 
# Output:
# Duplications: 23.4%  # ← 23% of code is duplicated
# Files: 156
# Clones: 42

Interpretation:

  • < 5%: Normal (sometimes duplication is justified)
  • 5-15%: Medium debt
  • > 15%: High debt, DRY principle violated

Example:

# ❌ Duplication (same code 3 times)
def create_user(data):
    if not data.get("email"):
        return {"error": "Email is required"}, 400
    if not re.match(r"^[\w\.-]+@[\w\.-]+\.\w+$", data["email"]):
        return {"error": "Invalid email"}, 400
    # ...
 
def update_user(data):
    if not data.get("email"):
        return {"error": "Email is required"}, 400
    if not re.match(r"^[\w\.-]+@[\w\.-]+\.\w+$", data["email"]):
        return {"error": "Invalid email"}, 400
    # ...
 
def send_invite(data):
    if not data.get("email"):
        return {"error": "Email is required"}, 400
    if not re.match(r"^[\w\.-]+@[\w\.-]+\.\w+$", data["email"]):
        return {"error": "Invalid email"}, 400
    # ...

✅ Refactored:

def validate_email(email):
    if not email:
        raise ValueError("Email is required")
    if not re.match(r"^[\w\.-]+@[\w\.-]+\.\w+$", email):
        raise ValueError("Invalid email")
    return email.lower()
 
def create_user(data):
    email = validate_email(data.get("email"))
    # ...

5. Bus Factor

What it measures: How many people need to get hit by a bus for the project to stop.

Formula:

Bus Factor = Number of key developers who know critical code

Tool:

# Git: who authored each line?
git ls-files | xargs -n1 git blame --line-porcelain | grep "^author " | sort | uniq -c | sort -rg
 
# Output:
# 12456 John Doe       # ← If John quits: project dies
#  3421 Alice Smith
#   987 Bob Johnson

Interpretation:

  • Bus Factor = 1: Critical. One person knows all the code.
  • Bus Factor = 2-3: Risk. Dependent on a few people.
  • Bus Factor ≥ 5: Healthy team.

Real-life example:

Senior went on vacation for 2 weeks. Production crashed. Nobody knew how authentication worked. Lost 3 days reverse-engineering our own code.

Solution:

  • Code review all code
  • Pair programming
  • Architecture documentation
  • Rotation (everyone should work with each module)

6. Debt Ratio

What it measures: Cost of fixing debt / Cost of writing code from scratch.

Formula (SonarQube):

Debt Ratio = (Remediation Cost / Development Cost) × 100%

Example:

Remediation Cost: 120 days (refactoring time)
Development Cost: 400 days (time spent on development)
Debt Ratio = (120 / 400) × 100% = 30%

Interpretation:

  • < 5%: Excellent code
  • 5-10%: Good
  • 10-20%: Medium debt
  • > 20%: Critical, refactoring or rewrite

Tool:

# SonarQube (free version)
docker run -d --name sonarqube -p 9000:9000 sonarqube:latest
# Run project analysis

7. Lead Time

What it measures: How long it takes to add a new feature.

Formula:

Lead Time = Time from commit to production

How to measure:

# JIRA/GitHub: average time "In Progress" → "Done"
# Or via git:
git log --all --format="%h %ai" | head -100

Interpretation:

  • Lead Time growing over time: Debt accumulating
  • Lead Time stable: Debt controlled
  • Lead Time decreasing: You're doing great (or features getting simpler)

Example:

PeriodAverage Lead TimeConclusion
Q1 20243 daysNormal
Q2 20245 daysDebt growing
Q3 20248 daysCritical
Q4 202414 daysCode turned into swamp

Growth reasons:

  • Code became more complex (Cyclomatic Complexity increased)
  • More regressions (Test Coverage dropped)
  • More merge conflicts (Weak architecture)

Refactor or not

Now you have metrics. What to do with them? Here's a 4-step decision framework.

Step 1: Estimate Cost of Debt

Formula:

Cost of Debt = Time on workarounds × Developer hourly rate

Example:

# Your code has:
def get_user_orders(user_id):
    # Workaround: no index on user_id, query is slow
    # Always add LIMIT 100 to avoid crash
    return db.query("SELECT * FROM orders WHERE user_id = ? LIMIT 100", user_id)
 
# Cost of debt:
# - Each new feature with orders requires workaround
# - Developers spend 30 minutes per workaround
# - 10 features per quarter = 5 hours
# - Developer hourly rate: $50
# Cost of Debt = 5 hours × $50 = $250/quarter

If cost > $1000/year: consider refactoring.


Step 2: Estimate Cost of Refactoring

Formula:

Cost of Refactoring = Refactoring time × Hourly rate + Regression risk

Example:

# Refactoring: add index on orders.user_id
# Time: 2 hours (migration + tests)
# Risk: low (index doesn't break logic)
# Cost of Refactoring = 2 hours × $50 + $0 (risk) = $100

Conclusion: $100 investment vs $250/quarter savings = pays off in 1.5 months.


Step 3: Estimate Opportunity Cost

Question: What will you NOT do while refactoring?

Example:

You plan 2 weeks for payment architecture refactoring. During this time:

  • Competitor will launch a new feature
  • You will NOT release planned feature
  • Customers will wait for bug fix

Formula:

Opportunity Cost = Potential gain from new features × Success probability

Example:

New feature: Subscription billing
Potential gain: $10k MRR
Success probability: 70%
Opportunity Cost = $10k × 0.7 = $7k

Payment refactoring:
Savings: $2k/year on maintenance
Opportunity Cost: $7k lost opportunity

Conclusion: DON'T refactor now. Feature first.

Step 4: Prioritization by Impact/Effort Matrix

Matrix:

High Impact │  REFACTOR NOW  │  SCHEDULE    │
            │  (quick wins)  │  (important) │
────────────┼────────────────┼──────────────┤
Low Impact  │  MAYBE         │  IGNORE      │
            │  (if free time)│  (tech vanity)│
────────────┴────────────────┴──────────────┘
            Low Effort       High Effort

Examples:

TaskImpactEffortDecision
Add index on user_idHighLow✅ REFACTOR NOW
Rewrite monolith to microservicesHighHigh📅 SCHEDULE
Rename variable x → userLowLow🤷 MAYBE
Rewrite in TypeScriptLowHigh❌ IGNORE

Full Decision Algorithm

def should_refactor(debt_item):
    # Step 1: Cost of debt
    cost_of_debt = estimate_debt_cost(debt_item)
 
    # Step 2: Cost of refactoring
    cost_of_refactoring = estimate_refactoring_cost(debt_item)
 
    # Step 3: Opportunity cost
    opportunity_cost = estimate_opportunity_cost(debt_item)
 
    # Step 4: Refactoring ROI
    roi = (cost_of_debt - cost_of_refactoring - opportunity_cost) / cost_of_refactoring
 
    # Decision rules
    if roi > 2.0:
        return "REFACTOR NOW"  # Pays off 2x
    elif roi > 1.0:
        return "SCHEDULE"       # Pays off, but not urgent
    elif roi > 0:
        return "MAYBE"          # Pays off, but better options exist
    else:
        return "IGNORE"         # Won't pay off

Usage example:

debt_item = {
    "name": "Add index on orders.user_id",
    "cost_of_debt": 250,        # $/quarter
    "cost_of_refactoring": 100, # $
    "opportunity_cost": 0       # Doesn't block features
}
 
roi = (250 - 100 - 0) / 100 = 1.5
# Conclusion: SCHEDULE (pays off in 1.5 months)

Cases

Payment gateway: we did not refactor

Situation:

E-commerce platform. Payment gateway written in 2018. Code worked, but:

  • No tests (coverage 0%)
  • Hardcode everywhere
  • 1 developer (Bus Factor = 1)

Debt Metrics:

  • Cyclomatic Complexity: 68 (critical)
  • Code Churn: 180 changes per year
  • Lead Time for payment features: 2 weeks (instead of 2 days)

Team decision: "Don't touch it. Works: don't fix."

What happened:

Black Friday 2023. Traffic grew 10x. Payment gateway crashed. Developer on vacation. Nobody knows how to fix.

Result:

  • 8 hours downtime
  • $500k lost sales
  • 2 weeks for emergency fix
  • 1 month for complete rewrite

Lesson: If Bus Factor = 1 on critical module: refactoring MANDATORY.

What should have been done:

  1. Add tests (1 week)
  2. Simplify code (1 week)
  3. Documentation (2 days)

Total: 2.5 weeks vs 1 month emergency + $500k losses.


Admin panel: we refactored for nothing

Situation:

SaaS startup. CTO decided to "clean up the code". Started with admin panel.

Debt Metrics:

  • Cyclomatic Complexity: 12 (normal)
  • Test Coverage: 75% (good)
  • Code Churn: 15 (low)

Decision: Rewrite admin in React instead of Django admin.

What happened:

  • 3 months of development
  • Competitors released 2 new features
  • Customers unhappy (no updates)
  • Refactoring brought NO business value

Result:

  • $400k opportunity cost
  • 2 key clients left for competitors
  • Team burnout

Lesson: Don't refactor what works and doesn't block business.

Red flags:

  • "Let's rewrite in trendy framework"
  • "This code is ugly" (but works)
  • "I don't like the architecture" (no business justification)

Two days and fifty thousand

Situation:

API service. Each endpoint duplicates authentication logic.

Debt Metrics:

  • Code Duplication: 32% (critical)
  • 150 lines of identical code in 45 files

Problem:

Bug in authentication logic → need to fix in 45 places → 2 days work → risk missing a file.

Solution:

2 days refactoring:

  1. Extract authentication to middleware
  2. Cover middleware with tests
  3. Remove duplicated code

Result:

  • Bug fix now: 15 minutes (instead of 2 days)
  • Savings: 20 bug fixes/year × 2 days × $300/day = $12k/year
    • reduced security error risk

ROI:

Cost: $600 (2 days × $300)
Savings: $12k/year
ROI: 20x in first year

Lesson: Code duplication in critical areas: first refactoring candidate.


Tools

1. SonarQube (best all-in-one)

Features:

  • Cyclomatic Complexity
  • Code Duplication
  • Test Coverage
  • Security vulnerabilities
  • Debt Ratio

Installation:

docker run -d --name sonarqube -p 9000:9000 sonarqube:latest

CI/CD Integration:

# .gitlab-ci.yml
sonarqube:
  script:
    - sonar-scanner -Dsonar.projectKey=my-project
  only:
    - main

2. CodeClimate (for GitHub)

Features:

  • Automatic pull request analysis
  • Debt trends
  • GitHub Actions integration

Price: $0 (open source), $199/month (private repos)


3. Radon (Python, free)

Installation:

pip install radon

Commands:

# Cyclomatic Complexity
radon cc -a src/
 
# Maintainability Index
radon mi src/
 
# Raw metrics (LOC, LLOC, SLOC)
radon raw src/

4. Git analytics (free)

Code Churn:

git log --since="6 months ago" --pretty=format: --name-only \
  | sort | uniq -c | sort -rg | head -10

Bus Factor:

git ls-files | xargs -n1 git blame --line-porcelain \
  | grep "^author " | sort | uniq -c | sort -rg

5. Codecov (Test Coverage)

Integration:

# .gitlab-ci.yml
test:
  script:
    - pytest --cov=src --cov-report=xml
    - bash <(curl -s https://codecov.io/bash)

Features:

  • Coverage trends
  • Pull request comments
  • Badges for README

Checklist

✅ REFACTOR NOW if:

  • Bus Factor = 1 on critical module
  • Cyclomatic Complexity > 20
  • Test Coverage < 50% on critical code
  • Security vulnerabilities (CVSS > 7.0)
  • Lead Time growing each quarter
  • Code Duplication > 30%
  • Production incidents related to this code

📅 SCHEDULE REFACTORING if:

  • Debt Ratio > 10%
  • Code Churn > 100 in 6 months
  • Blocks new features (but not critical)
  • Team growing (newcomers drowning in code)
  • Onboarding takes > 1 month

❌ DON'T REFACTOR if:

  • Code works and doesn't block business
  • ROI < 1.0 (won't pay off)
  • Opportunity cost high (important features exist)
  • "Just ugly" (no business pain)
  • Module planned for deletion in next 6 months

How to run it

1. Debt Sprint (20% time on refactoring)

Rule: Every 5th sprint is a debt sprint.

What we do:

  • Fix top-10 debt items (by metrics)
  • Add tests to critical modules
  • Update documentation

Example:

Sprint 1-4: Features
Sprint 5: Debt Sprint
  - Task 1: Cover payment gateway with tests (coverage 0% → 80%)
  - Task 2: Refactor UserModel (complexity 45 → 12)
  - Task 3: Add indexes to top-10 slow queries

2. Boy Scout Rule

Rule: Leave code cleaner than you found it.

How to apply:

# You're fixing a bug in process_payment()
# See that complexity is 25
# Spend +30 minutes on refactoring
# Complexity becomes 10
# Commit: "Fix payment bug + refactor for clarity"

Limitations:

  • No more than 30% time on refactoring
  • Don't change architecture (only local improvements)

3. Debt Dashboard

Metrics on dashboard:

  • Debt Ratio (SonarQube)
  • Test Coverage (Codecov)
  • Lead Time (Jira/GitLab)
  • Code Churn (Git)

Example:

┌─────────────────────────────────────────┐
│ Technical Debt Dashboard        Q4 2025 │
├─────────────────────────────────────────┤
│ Debt Ratio:         12% ⚠️ (was 8%)    │
│ Test Coverage:      78% ✅ (was 75%)   │
│ Lead Time:         6 days 📈 (was 5)   │
│ Critical Issues:        3 🚨           │
└─────────────────────────────────────────┘

Review: Quarterly with team. Discuss trends.


4. Debt Tag in Pull Requests

Rule: If PR increases debt: add [DEBT] tag.

Example:

PR #123: [DEBT] Quick fix for payment bug

Debt:
- Added hardcode for Stripe API key (should be in env)
- Skipped tests (time constraint)
- Complexity increased from 12 to 18

Action item:
- Created task DEBT-456 to refactor in next sprint

Benefits:

  • Conscious debt (not accidental)
  • Tracking (know where debt accumulates)
  • Planning (DEBT tasks in backlog)

Debt is not morality

A mortgage is not evil. A loan with no payment schedule is not a loan.

Debt for an MVP is fine. Debt for a deadline is sometimes justified. Years without payment is default, even if the repo is green.

Four questions: where is the debt (metrics)? what does it cost (Cost of Debt)? is there a backlog item? when do we pay (debt sprint)?

If two of four are no, this is hope, not management.


What to Do Right Now

Step 1: Measure Debt (30 minutes)

# 1. Code Churn
git log --since="6 months ago" --pretty=format: --name-only \
  | sort | uniq -c | sort -rg | head -10
 
# 2. Cyclomatic Complexity (Python)
pip install radon
radon cc -a src/
 
# 3. Test Coverage
pytest --cov=src --cov-report=term-missing
 
# 4. Bus Factor
git ls-files | xargs -n1 git blame --line-porcelain \
  | grep "^author " | sort | uniq -c | sort -rg

Step 2: Prioritize (1 hour)

Create a table:

Debt ItemImpactEffortROIAction
Payment gatewayHighMedium5.0REFACTOR NOW
Admin panelLowHigh0.2IGNORE
User modelMediumLow2.5SCHEDULE

Step 3: Schedule (10 minutes)

  • Create tasks in Jira/GitLab for top-3 debt items
  • Add 1-2 tasks to next sprint
  • Schedule Debt Sprint in 4 weeks

Step 4: Automate (1 hour)

# .gitlab-ci.yml
debt-analysis:
  stage: test
  script:
    - pip install radon
    - radon cc -a src/ --total-average
    - pytest --cov=src --cov-report=term
  only:
    - main