The most common misconception about writing AI prompts is that a prompt is a sentence you type into a text box. It is not. A prompt is a specification you build — a structured set of constraints that narrows the model’s output space the same way a function signature narrows what a compiler will accept. Treat it as prose and you’ll get whatever the model’s statistical average happens to be. Treat it as a spec and you’ll get something that does what you asked on the first pass.
This tutorial walks through one concrete task from start to finish: getting an AI model to refactor a poorly-structured Python function. You’ll see the exact prompts I use, why each component matters, and the measurable difference between a vague request and a tight specification. By the end, you’ll have a repeatable template you can apply to any task — code review, document drafting, data analysis, or anything else — because the underlying mechanics are identical regardless of the domain.
The Setup: One Task, Three Attempts
Let’s define the task precisely. You have a Python function that processes a list of transaction dictionaries and returns a summary. It works, but it’s hard to read, mixes concerns, and has a bug that only appears when a transaction has no category field. Your goal is to get the model to produce a cleaned-up version that fixes the bug without changing the external behavior.
Here is the starting code:
def process_transactions(transactions):
total = 0
categories = {}
for t in transactions:
if t["amount"] < 0:
continue
total += t["amount"]
if t["category"] in categories:
categories[t["category"]] += t["amount"]
else:
categories[t["category"]] = t["amount"]
return {"total": total, "categories": categories}
The bug is on line 5 and line 9: if a transaction is missing the category key, the function raises a KeyError. The function also mixes two responsibilities — summing totals and grouping by category — which makes testing awkward.
Most beginners would type something like this into a chat interface:
“Refactor this code, it’s messy.”
That prompt produces output, but the output rarely matches what you need. The model doesn’t know what “messy” means to you. It doesn’t know whether you care about performance, readability, testability, or adherence to a specific style guide. It will pick a default interpretation, and that default is rarely the one you had in mind.
The fix is not to write a longer sentence. The fix is to build a structured prompt that removes ambiguity the same way you’d remove ambiguity from a ticket before handing it to a junior engineer.
Step 1: Name the Operation Explicitly
The first thing a prompt needs is an unambiguous verb. “Refactor this code” is a topic, not an operation. The model has to guess between dozens of possible actions: restructure it, make it more readable, make it faster, fix the bug, add type hints, split it into smaller functions, or any combination thereof.
The same ambiguity appears outside code tasks. “Help me with this email” could mean draft it, rewrite it, shorten it, or check it for tone problems. “Look at this data” could mean summarize it, find anomalies, or generate a chart. The model defaults to the safest, most generic interpretation, which is why you get commentary about the code instead of the code itself.
Here is the operation clause I use for this task:
“Refactor the following Python function to eliminate the KeyError bug and separate the aggregation logic from the grouping logic.”
Two operations, both named with precise verbs: “eliminate” and “separate.” No “help me with,” no “make it better,” no “improve.” The model knows exactly what the end state should be — a function that doesn’t crash on missing keys and has two distinct responsibilities.
If you’re working with a model that supports a system message or API parameters, this operation clause belongs at the top of the system prompt. If you’re using a chat interface, put it at the start of your message. The position matters less than the clarity — but position does help, because models weight earlier tokens more heavily in the context window.
Step 2: Provide Minimal Necessary Context
The second layer of a prompt is context: the information that distinguishes your case from every other case the model has seen. For code tasks, this includes the language, the framework, the version, and the constraints you’re operating under.
For this task, the minimal context is:
- The language is Python 3.10+.
- The project follows PEP 8 style.
- The function is called from two places in an existing codebase, so the return type and the parameter types cannot change.
- Performance is not a concern right now; the transaction list is never larger than a few thousand entries.
Here is how that context gets folded into the prompt:
“Python 3.10+. Project follows PEP 8. The function signature and return type must remain unchanged — callers depend on them. Performance is not a constraint at this scale.”
That is four sentences. It took me about fifteen seconds to write. The cost of omitting it is that the model might introduce type hints that require Python 3.9 syntax if it guesses the version wrong, or rename the return keys, or restructure the logic in a way that changes behavior under edge cases. The context clause eliminates an entire class of plausible-but-wrong outputs.
For non-code tasks, the same principle applies. “Explain compound interest to a teenager” is a context clause. “Draft a follow-up email to a client who hasn’t responded in two weeks” is a context clause. Anything that pins down your specific situation reduces the space of generic completions the model can fall back on.
Step 3: State Format Constraints
The third layer is format — the shape of the output. Beginners almost always skip this, and it’s the single highest-leverage addition you can make.
Without a format constraint, the model decides whether to return a single code block, code plus explanation, a diff, or a full rewrite with annotations. It might add a paragraph of apology about the original code, or a disclaimer that it can’t know your full context. Each of those additions wastes tokens and obscures the actual output you need.
For code refactoring, I want two things in a specific order: the full refactored function in a single fenced code block, and then a separate list of the exact changes made, each with the line number from the original. That gives me a diff I can review without running a separate tool.
Here is the format clause:
“Return the refactored function in a single Python code block. After the code block, list each change you made as a bullet point, referencing the original line number.”
That’s two sentences. It eliminates every possible format variation and forces the output into a shape I can paste directly into my editor.
The same pattern applies to any task: “Respond in exactly three bullet points, each under twenty words,” “Format the comparison as a markdown table with columns for Name, Cost, and Latency,” “Keep the total response under 150 words.” Every format constraint you add removes one more degree of freedom from the model’s output, which means fewer surprises.
Step 4: Isolate Negative Constraints
The fourth layer addresses what you do not want. This is the most underused part of prompt engineering, because it requires thinking about failure modes before you see them.
For this task, I have three specific exclusions:
- Do not change the return key names (
totalandcategories). - Do not add type hints for the
transactionsparameter if it would require importingListfromtyping— the project usesfrom __future__ import annotationsonly in new files, and this file predates that. - Do not add explanatory comments inside the code block — I want the code clean; the change list outside the block is where explanations belong.
Here is how those get stated:
“Constraints: Keep the return keys ’total’ and ‘categories’ exactly as they are. Do not add type hints to the signature. Do not put comments inside the code block — explain changes in the bullet list instead.”
Each constraint is its own sentence, isolated rather than buried in a long paragraph. In testing, this isolation matters: constraints stated on their own lines are followed more reliably than constraints embedded mid-sentence in a longer instruction. The model’s attention mechanism weights discrete chunks more effectively than prose.
For non-code tasks, negative constraints work the same way. “Avoid generic phrases like ‘perfect for any occasion.’” “Do not include a disclaimer about consulting a professional.” “Skip the introduction — start directly with the first recommendation.” Name the thing you don’t want, put it on its own line, and the model can comply. Leave it implicit and you’ll get it anyway, because the statistical average of every similar output includes it.
The Complete Prompt
Here is the full prompt assembled from the four layers:
Refactor the following Python function to eliminate the KeyError bug and separate the aggregation logic from the grouping logic.
Python 3.10+. Project follows PEP 8. The function signature and return type must remain unchanged — callers depend on them. Performance is not a constraint at this scale.
Return the refactored function in a single Python code block. After the code block, list each change you made as a bullet point, referencing the original line number.
Constraints: Keep the return keys ’total’ and ‘categories’ exactly as they are. Do not add type hints to the signature. Do not put comments inside the code block — explain changes in the bullet list instead.
def process_transactions(transactions): total = 0 categories = {} for t in transactions: if t["amount"] < 0: continue total += t["amount"] if t["category"] in categories: categories[t["category"]] += t["amount"] else: categories[t["category"]] = t["amount"] return {"total": total, "categories": categories}
Four blocks, each with a distinct job. The operation clause tells the model what to do. The context clause tells it under what conditions. The format clause tells it what shape to return. The negative constraints tell it what to avoid.
If you’re using an API rather than a chat interface, here is how this same prompt translates to a function call:
import openai
response = openai.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You are a senior Python engineer. Refactor code precisely, preserving external behavior."},
{"role": "user", "content": prompt_text},
],
temperature=0.2,
)
print(response.choices[0].message.content)
The temperature=0.2 setting matters for code tasks — it reduces the randomness of the output. For code, you want the most deterministic completion the model can produce, not creative variation. For brainstorming tasks, you’d raise it to 0.8 or higher. This is a parameter people miss when they move from a chat interface to an API, and it measurably changes output quality for structured tasks.
What Comes Back, and How to Verify It
The output from the structured prompt above, in practice, looks like this:
def process_transactions(transactions):
total = 0.0
categories = {}
for t in transactions:
if t.get("amount", 0) is None or t["amount"] < 0:
continue
amount = t["amount"]
total += amount
category = t.get("category", "uncategorized")
categories[category] = categories.get(category, 0) + amount
return {"total": total, "categories": categories}
Changes list:
- Line 5 — replaced
t["amount"] < 0witht.get("amount", 0) is None or t["amount"] < 0to handle missingamountkeys without raising KeyError. - Line 5 — added
amount = t["amount"]to avoid repeated dictionary lookups. - Line 9 — replaced
if t["category"] in categorieswitht.get("category", "uncategorized")andcategories.get(category, 0)to handle missing category keys and simplify the branch logic.
The bug is fixed: the get method with a default value handles missing keys, and the categories.get pattern removes the else branch entirely. The function now assigns missing categories to “uncategorized” rather than crashing. The return keys are unchanged, so callers keep working. No type hints were added. No comments inside the code block.
But the verification step is where beginners often stop too early. The output looks correct, so they trust it. The correct verification step is to test it against the same inputs you’d run through your test suite. Here is the test I run:
def test_process_transactions():
transactions = [
{"amount": 100, "category": "food"},
{"amount": 250, "category": "food"},
{"amount": 80, "category": "transport"},
{"amount": -50, "category": "refund"}, # negative, should be skipped
{"amount": 30}, # missing category
]
result = process_transactions(transactions)
assert result["total"] == 460 # 100 + 250 + 80 + 30, -50 skipped
assert result["categories"] == {"food": 350, "transport": 80, "uncategorized": 30}
test_process_transactions()
This test asserts the specific behavior you care about. If the model’s refactor breaks any of these assertions, you know exactly what to fix — and you can go back to the same conversation, name the specific delta (“the total is off by 30 because the negative amount check is too broad”), and get a targeted fix without rebuilding the prompt from scratch.
When the Structured Prompt Is the Wrong Tool
The four-layer structure I’ve described is not universally the right approach. There are cases where a looser prompt is better, and knowing when to skip the structure saves you time.
Creative tasks — brainstorming product names, drafting marketing copy, exploring alternative approaches to a design problem. Here, format constraints and negative constraints work against you. The model’s statistical average is exactly what you want to explore, because it surfaces options you hadn’t considered. Imposing stiffness on a creative task produces boilerplate instead of variation.
Tasks where you don’t know the output shape yet — you’re analyzing a dataset and you don’t know what insights exist, or you’re exploring a codebase and you don’t know what the problem is. In these cases, the format clause is premature — you don’t know what format the answer should take until you see the content. Start loose, find the shape, then tighten on subsequent turns.
One-off quick questions — “What’s the difference between a list and a tuple in Python?” The full four-layer structure costs more time than it saves. The question is already specific, the output shape is predictable, and the risk of a bad answer is low. Use a plain sentence and move on.
When you’re using the model as a source of statistical information, not as a task performer — “What are the most common failure modes for Redis caching layers?” Here, the model’s training data is the actual object of interest, and a generic prompt surfaces more of it. Tightening the prompt only narrows the information you retrieve.
The rule of thumb: the more you care about the exact shape and content of the output, the more structure you need. The more you’re exploring unknown territory, the less structure you want.
The Reusable Template
Strip away the code-specific details and the four-layer structure reduces to this:
- Operation — one sentence with an explicit verb that names the exact action.
- Context — two to four sentences that pin down your specific situation.
- Format — one to two sentences stating the output shape: length, structure, order.
- Negative constraints — one line each for anything you specifically don’t want.
That’s the entire system. It works for code refactoring, document drafting, data analysis, meeting summaries, legal review, and every other task where you need a consistent, usable output on the first attempt.
The reason the structure works is not mystical. A model’s output is a probability distribution over tokens, and each constraint you add shifts that distribution. Fewer degrees of freedom means the model has fewer places to guess, and fewer guesses means fewer surprises. The same principle applies whether you’re writing a prompt, a function signature, or an API contract — the tighter the specification, the more predictable the result.
Next time you sit down to write a prompt, resist the urge to type a paragraph of English prose. Write the four clauses instead. The first time it will feel mechanical. By the third or fourth time, it will be reflexive — and the outputs will be measurably closer to what you needed.
🔗 Recommended Reading
- How to Use AI Prompts for Email Marketing Campaigns That Convert
- How to Use AI for Market Research and Competitor Analysis: A Step-by-Step Field Guide
- Are AI Prompt Engineering Certifications Worth It? A 2025 Review
- Building Multi-LLM Ensembles: Combining Outputs for Better Results
- AI Prompting Techniques for Summarizing Long Documents and Reports