Prompt Engineering for Code Generation
Master prompt engineering techniques for generating, debugging, and refactoring code with LLMs. Includes practical patterns, templates, and Python examples for developers.
What you'll learn
- ✓How to write prompts that produce working code
- ✓Patterns for debugging and refactoring with LLMs
- ✓Context management for multi-file projects
- ✓Testing and validation of generated code
Prerequisites
- •Programming experience in at least one language
- •Basic familiarity with LLM APIs
Using LLMs for code generation is straightforward when you need a quick function. It gets harder when you need production-quality code that fits into an existing codebase, follows your conventions, handles edge cases, and passes your tests. The difference between a mediocre prompt and a great one can mean the difference between usable code and code that needs a complete rewrite.
This guide covers prompt patterns that consistently produce better code from LLMs, whether you are generating new features, debugging issues, or refactoring existing code.
Why Most Code Prompts Fail
The most common mistake is being too vague. “Write a function to process user data” gives the model no constraints, so it makes dozens of assumptions about data format, error handling, return types, and naming conventions. The result might work but probably does not fit your application.
Good code prompts are specific about inputs, outputs, constraints, style, and error handling. They give the model enough context to write code that actually belongs in your project.
The Specification Prompt Pattern
The most reliable pattern for code generation is to write a clear specification before asking for code.
prompt = """Write a Python function with the following specification:
Function name: parse_csv_transactions
Module: app/parsers/transactions.py
Purpose: Parse a CSV file of bank transactions and return structured data.
Input:
- file_path (str): Path to CSV file
- CSV columns: date, description, amount, category (optional)
- Date format: MM/DD/YYYY
- Amount: can be negative (debits) or positive (credits)
- File may have a header row
Output:
- List of Transaction dataclass objects with fields:
- date: datetime.date
- description: str (stripped of extra whitespace)
- amount: Decimal (not float, we need precision)
- category: Optional[str] (default "uncategorized")
Error handling:
- Raise ValueError for malformed rows with the row number in the message
- Skip completely empty rows
- Handle BOM characters in UTF-8 files
- Raise FileNotFoundError if file does not exist
Constraints:
- Use standard library only (csv, decimal, dataclasses, datetime)
- Type hints on all parameters and return values
- Docstring with Args and Returns sections
- Follow PEP 8 naming conventions"""
This prompt leaves very little room for ambiguity. The model knows exactly what to build, what types to use, how to handle errors, and what style conventions to follow. Compare this to “write a CSV parser” and the quality difference is dramatic.
Providing Existing Code Context
When generating code that fits into an existing project, provide relevant context from your codebase.
def build_code_gen_prompt(task: str, context_files: dict[str, str]) -> str:
"""Build a prompt with codebase context for code generation."""
context_section = "\n\n".join(
f"### {filepath}\n```python\n{content}\n```"
for filepath, content in context_files.items()
)
return f"""You are adding code to an existing Python project.
Here is the relevant existing code for context:
{context_section}
TASK: {task}
REQUIREMENTS:
- Follow the same coding style, naming conventions, and patterns as the existing code
- Use the same import style (absolute vs relative)
- Match the existing error handling patterns
- Ensure new code integrates with the existing classes and functions shown above
- Add type hints consistent with the existing codebase
Write only the new code needed. Do not rewrite existing files unless changes are required for integration."""
# Example usage
context = {
"app/models/base.py": """
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from datetime import datetime
class Base(DeclarativeBase):
pass
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(default=datetime.utcnow, onupdate=datetime.utcnow)
""",
"app/models/user.py": """
from app.models.base import Base, TimestampMixin
from sqlalchemy.orm import Mapped, mapped_column
class User(Base, TimestampMixin):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(unique=True)
name: Mapped[str]
"""
}
prompt = build_code_gen_prompt(
task="Create an Order model with fields: id, user_id (foreign key to users), total (Decimal), status (enum: pending/paid/shipped/delivered)",
context_files=context
)
By showing the model your existing code, it will match your ORM style, import patterns, and naming conventions automatically.
Debugging Prompts
When using LLMs to debug code, provide the full error context, not just the error message.
debug_prompt = """I have a bug in my Python application. Help me find and fix it.
## The Code
```python
import asyncio
import aiohttp
async def fetch_all(urls: list[str]) -> list[dict]:
results = []
async with aiohttp.ClientSession() as session:
tasks = [fetch_one(session, url) for url in urls]
results = await asyncio.gather(*tasks)
return results
async def fetch_one(session, url):
async with session.get(url) as response:
return await response.json()
def main():
urls = ["https://api.example.com/1", "https://api.example.com/2"]
results = asyncio.run(fetch_all(urls))
print(results)
Error
RuntimeError: Event loop is already running
Context
- This runs inside a Jupyter notebook
- Python 3.11
- aiohttp 3.9.1
- The same code works fine when run as a script
What I have tried
- Using asyncio.get_event_loop().run_until_complete() instead of asyncio.run()
- This gave the same error
Please explain the root cause and provide a fix that works in both Jupyter and script contexts."""
The key elements of a good debug prompt are the code, the exact error, the environment, and what you have already tried. Without these, the model guesses at the problem and often suggests fixes for the wrong issue.
## Refactoring Prompts
Refactoring prompts work best when you clearly state the goal and constraints.
```python
refactor_prompt = """Refactor the following code to improve readability and maintainability.
## Current Code
```python
def process(data):
r = []
for item in data:
if item.get('type') == 'A':
if item.get('status') == 'active':
if item.get('value', 0) > 100:
r.append({'id': item['id'], 'result': item['value'] * 1.1, 'tier': 'premium'})
else:
r.append({'id': item['id'], 'result': item['value'] * 1.05, 'tier': 'standard'})
elif item.get('type') == 'B':
if item.get('status') == 'active':
r.append({'id': item['id'], 'result': item['value'] * 0.95, 'tier': 'discount'})
return r
Refactoring Goals
- Replace nested if/else with early returns or guard clauses
- Extract the pricing logic into a separate function
- Use dataclasses instead of raw dicts
- Add type hints
- Keep the same input/output behavior (do not change the public interface)
Constraints
- Do not add external dependencies
- Keep it in a single file
- Add a brief docstring to each function"""
## Generating Tests
LLMs excel at generating test cases, especially when you give them the function signature and specify edge cases to cover.
```python
test_prompt = """Write pytest tests for the following function:
```python
def validate_password(password: str) -> tuple[bool, list[str]]:
\"\"\"Validate password strength.
Returns:
Tuple of (is_valid, list_of_error_messages).
is_valid is True only if all rules pass.
Rules:
- Minimum 8 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one digit
- At least one special character (!@#$%^&*)
- No more than 3 consecutive identical characters
- Cannot contain the word 'password' (case insensitive)
\"\"\"
Write tests covering:
- A valid password that passes all rules
- Each individual rule failure (one test per rule)
- Multiple simultaneous failures
- Edge cases: empty string, very long password (1000+ chars), unicode characters
- Boundary conditions: exactly 8 characters, exactly 3 consecutive identical characters vs 4
Use pytest parametrize where appropriate. Follow the Arrange-Act-Assert pattern. Name tests descriptively: test_validate_password_rejects_short_input."""
## Multi-File Generation
For larger features, break the task into files and specify the relationships between them.
```python
multi_file_prompt = """Generate a complete CRUD API module for a "Product" resource.
## Project Structure to Generate:
app/ products/ init.py (exports router) models.py (SQLAlchemy model) schemas.py (Pydantic schemas for request/response) router.py (FastAPI router with CRUD endpoints) service.py (Business logic layer) exceptions.py (Custom exceptions)
## Product Fields:
- id: UUID (auto-generated)
- name: str (1-200 chars)
- description: Optional[str] (max 2000 chars)
- price: Decimal (positive, max 2 decimal places)
- sku: str (unique, alphanumeric, 8-12 chars)
- is_active: bool (default True)
- created_at, updated_at: datetime (auto-managed)
## Requirements:
- FastAPI with async SQLAlchemy
- Pagination on list endpoint (page/size params)
- Filtering by is_active and price range
- Proper HTTP status codes (201 for create, 404 for not found, 409 for duplicate SKU)
- Service layer handles business logic, router handles HTTP concerns
- Custom exceptions translated to HTTP errors via exception handlers
## Existing Project Conventions:
- Use `from app.database import get_session` for DB sessions
- Use `from app.models.base import Base, TimestampMixin` for model base
- Async session: `AsyncSession` from sqlalchemy.ext.asyncio
- All endpoints prefixed with /api/v1/
Generate each file separately with the filename as a header."""
Iterative Refinement
The best results come from iterating on generated code. Use a review-then-refine pattern.
import openai
client = openai.OpenAI()
def generate_and_refine(spec: str, max_rounds: int = 3) -> str:
"""Generate code then iteratively refine it."""
messages = [
{"role": "system", "content": "You are a senior Python developer. Write clean, tested, production-ready code."},
{"role": "user", "content": spec}
]
# Round 1: Initial generation
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
temperature=0.2
)
code = response.choices[0].message.content
messages.append({"role": "assistant", "content": code})
# Round 2: Self-review
messages.append({
"role": "user",
"content": """Review the code you just wrote. Check for:
1. Missing error handling or edge cases
2. Type hint completeness
3. Potential performance issues
4. Security concerns (SQL injection, path traversal, etc.)
5. Missing input validation
List all issues found, then provide the corrected code."""
})
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
temperature=0.1
)
return response.choices[0].message.content
Prompt Templates for Common Tasks
Keep a library of prompt templates for recurring code generation tasks.
TEMPLATES = {
"api_endpoint": """Create a FastAPI endpoint:
- Method: {method}
- Path: {path}
- Request body: {request_schema}
- Response: {response_schema}
- Auth: {auth_required}
- Error cases: {error_cases}
Follow RESTful conventions. Include input validation.""",
"data_migration": """Write a data migration script:
- Source: {source_description}
- Destination: {destination_description}
- Transformations: {transformations}
- Volume: ~{row_count} rows
- Requirements: idempotent, resumable, with progress logging
- Batch size: {batch_size}""",
"cli_command": """Create a CLI command using click:
- Command name: {name}
- Arguments: {arguments}
- Options: {options}
- Behavior: {behavior}
- Include --dry-run flag
- Add proper help text for all arguments and options"""
}
def fill_template(template_name: str, **kwargs) -> str:
return TEMPLATES[template_name].format(**kwargs)
Tips for Better Code Generation
Be specific about error handling. “Handle errors appropriately” is vague. “Raise ValueError for invalid inputs with a descriptive message, and let IOError propagate to the caller” is actionable.
Specify the testing approach. If you want testable code, say so. Ask for dependency injection, pure functions, or specific test patterns.
Include negative requirements. “Do not use global state” or “Do not use print statements for logging” prevents common issues.
Ask for incremental output. For complex features, ask the model to generate one component at a time rather than everything at once. This produces better results and is easier to review.
Provide your linter configuration. If you use specific linting rules, mention them. “Follow ruff defaults” or “compatible with mypy strict mode” helps the model produce code that passes your CI pipeline.
Wrapping Up
Effective code generation prompts come down to being explicit about what you want. Specify the function signature, input and output types, error handling behavior, naming conventions, and constraints. Provide context from your existing codebase so generated code fits naturally. Use iterative refinement to catch issues the initial generation misses. And always review generated code carefully before committing it, just as you would review a pull request from any contributor.
The model is a powerful tool for accelerating development, but the quality of its output is directly proportional to the quality of your prompt. Invest time in writing clear specifications, and you will get code that needs minimal editing to ship.
Related articles
- Prompt Engineering Get Structured JSON Output from LLMs
Learn how to prompt LLMs to return structured JSON output reliably. Covers schema enforcement, Pydantic validation, and production patterns for consistent structured responses.
- Prompt Engineering Prompt Chaining: Build Multi-Step LLM Workflows
Learn how to chain multiple LLM prompts together to solve complex tasks. Covers sequential chains, branching, validation loops, and production patterns in Python.
- Prompt Engineering Prompt Injection: What It Is and How to Prevent It
Understand prompt injection attacks against LLM applications. Learn detection strategies, input sanitization, defense-in-depth patterns, and how to build resilient AI systems.
- Prompt Engineering Prompt Engineering Anti-Patterns: Mistakes That Quietly Hurt Quality
A field guide to the most common prompt engineering anti-patterns, why they degrade LLM output quality, and concrete refactors that fix each one.