Copilot for Legacy Code: A Practical Guide

# Copilot for Legacy Code: A Practical Guide

Legacy code is the backbone of most software systems—and the thing developers dread touching most. You’ve seen it: tangled dependencies, undocumented business logic, tests that might as well be hieroglyphics. In 2026, GitHub Copilot is mature enough to be genuinely useful on these codebases, but it requires a different approach than greenfield development.

This isn’t about pasting magic prompts and watching refactoring happen. It’s about understanding where Copilot excels, where it struggles, and how to use it as a force multiplier without introducing new bugs into systems that already have too many.

## Why Legacy Code Breaks Copilot

Copilot trained on modern code—clean repos, documented APIs, current patterns. Legacy code often predates these conventions by years or decades. The model struggles with three things specifically:

1. **Outdated patterns**: Code written in 2015 uses different idioms than what Copilot expects
2. **Missing context**: No README, no docs, no one left who remembers why this exists
3. **Implicit knowledge**: Business logic lives in variable names like `x` and `doStuff()`, not in clear intent

When you open a 3000-line file with no context, Copilot sees noise. It can’t infer the domain rules that someone embedded in poorly-named functions five years ago.

The fix isn’t to use better prompts—it’s to give Copilot the context it needs to make reasonable suggestions.

## Setting Up Copilot for Legacy Projects

Before you write a single line, configure your environment. This takes five minutes and saves hours of frustration.

“`bash
# Create a .github/copilot-instructions.md file
# This tells Copilot about your specific codebase
echo “# Legacy Code Guidelines
# Language: Python 3.8 (cannot upgrade until Q3)
# Framework: Django 2.2 (LTS, not moving until 2027)
# Patterns: Service layer in services/, models in models/
# Avoid: Celery tasks (use Django Q instead), raw SQL in views”
> .github/copilot-instructions.md
“`

Copilot reads this file when working in your repo. Add specifics about:
– Framework versions and constraints
– Naming conventions your team uses
– Files or directories to avoid
– Business domain terms it should recognize

In VS Code, also enable these settings:

“`json
{
“github.copilot.advanced”: {
“inlineSuggest”: true,
“lengthLimit”: 2000
},
“github.copilot.chat”: {
“localHelpEnabled”: true
}
}
“`

The `localHelpEnabled` setting makes Copilot search your repo for relevant code before answering—critical for legacy projects where the answer often exists elsewhere in the codebase.

## Prompt Engineering for Old Codebases

Standard prompts work poorly on legacy code. Here’s what works:

**Instead of:** “Refactor this function”
**Try:** “Extract this into a separate service function. Keep the same inputs/outputs. Add type hints. The business rule is: this calculates monthly retention for SaaS accounts.”

See the difference? You’re providing:
– The transformation goal
– Constraints (same I/O)
– Modernization target (type hints)
– Domain context (what retention means here)

For understanding unfamiliar code, use this pattern:

“`
Explain what this function does in 2 sentences.
Then list:
1. All inputs and their types
2. All side effects (file I/O, DB calls, external APIs)
3. Any business rules encoded in the logic
4. Why it might have been written this way
“`

Copilot will often surface context that documentation never captured. I’ve seen it identify side effects that weren’t commented anywhere.

## Real Examples: Copilot in Action

Let’s walk through three common scenarios.

### Scenario 1: Adding Tests to Untested Code

“`python
# Legacy function – no tests exist
def calculate_discount(customer_type, order_total, loyalty_years):
if customer_type == “premium”:
return order_total * 0.15
elif customer_type == “standard”:
if loyalty_years > 5:
return order_total * 0.10
return order_total * 0.05
return 0
“`

Prompt Copilot Chat: “Write pytest tests for this function. Cover: premium customer (expect 15% off), standard with >5 years loyalty (expect 10%), standard with <5 years (expect 5%), unknown type (expect 0). Use parametrize." It generates this: ```python import pytest from your_module import calculate_discount @pytest.mark.parametrize("customer_type,order_total,loyalty_years,expected", [ ("premium", 100.0, 0, 15.0), ("premium", 200.0, 10, 30.0), ("standard", 100.0, 6, 10.0), ("standard", 100.0, 3, 5.0), ("unknown", 100.0, 0, 0), ("standard", 100.0, 5, 5.0), # boundary at 5 years ]) def test_calculate_discount(customer_type, order_total, loyalty_years, expected): assert calculate_discount(customer_type, order_total, loyalty_years) == expected ``` The boundary test at exactly 5 years is a bonus—it caught a potential off-by-one bug. ### Scenario 2: Understanding Cryptic Code ```python # What does this even do? def x(a, b): if a.get('t') == 'vip': return b['p'] * 0.9 return b['p'] ``` Prompt: "Explain this code. What do 't', 'vip', and 'p' likely represent? What business rule does this encode?" Copilot response ( paraphrased): "This applies a 10% discount to orders when the customer type is 'vip'. The 't' field likely represents customer tier/type, and 'p' represents price. This is a simple VIP discount rule." Now you have context. You can ask follow-ups: "Add a new tier 'enterprise' that gets 20% off" and Copilot understands the domain. ### Scenario 3: Safe Refactoring Prompt: "Add type hints to this function. Add a docstring explaining the parameters and return value. Do NOT change the logic, only add annotations." This is the safest way to use Copilot on legacy code—modernization without behavior change. You're asking it to make explicit what was implicit, reducing the risk surface before larger refactors. ## When Copilot Fails Be honest about the limitations: - **It invents APIs**: If your legacy codebase uses custom internal libraries, Copilot may suggest functions that don't exist. Always verify imports. - **It misses security issues**: It won't flag SQL injection in old code or hardcoded credentials. Run separate security scans. - **It can't test business logic**: If the business rule is undocumented, Copilot can guess, but you need a domain expert to verify. - **It struggles with non-English comments**: Code with comments in other languages confuses the model. When Copilot suggests something that doesn't compile, that's not failure—that's normal. When it suggests something that compiles but does the wrong thing, that's where you need human oversight. ## A Workflow That Works Here's what actually moves the needle on legacy code: 1. **Start with understanding**: Use Copilot to explain code before touching it 2. **Add tests first**: Generate test coverage for the behavior you need to preserve 3. **Modernize incrementally**: Add type hints, rename variables, extract functions—one small change at a time 4. **Verify at each step**: Run tests after every Copilot-assisted change 5. **Document as you go**: Add the context Copilot helped you discover to the codebase This approach treats Copilot as a pair programmer who happens to have read all of GitHub—not an oracle that produces perfect code. ## Key Takeaways - Legacy code lacks the context Copilot needs; provide it via `.github/copilot-instructions.md` - Use domain-specific prompts that explain business rules, not just "refactor this" - Generate tests before refactoring—tests are your safety net - Copilot excels at understanding and annotating code; it needs supervision for behavior changes - Always verify suggestions compile and test pass before committing ## Next Steps 1. Create a `copilot-instructions.md` file in your largest legacy repo right now 2. Pick one function that's been bugging you and ask Copilot to explain it 3. Generate tests for that function using the pattern from Scenario 1 4. Make one small modernization change (type hints, better naming) 5. Run your test suite and verify nothing broke Legacy code doesn't get less scary by waiting. But with Copilot as a tool rather than a magic wand, you can make incremental progress without blowing up production.