Prompt Engineering for Production Code: Context First, Tagged Examples, Positive Constraints, and an Acceptance Check

Prompt Engineering for Developers: Patterns That Produce Production-Ready Code Every Time
32 views

Ask a model to “write a rate limiter” and you get a tutorial: plausible code with the wrong error class, print for logging, a broad except and a clock you can’t control in tests. The model isn’t careless. It has no idea how your codebase works unless the prompt shows it. This guide covers the prompt patterns that close that gap, in the order the model documentation recommends, and then the part most guides skip: an automatic acceptance check, because a better prompt raises the odds but doesn’t guarantee anything.

⚡ TL;DR
  • Show before you ask: put your relevant files at the top of the prompt and the task at the end. Anthropic’s guidance says queries at the end can improve response quality by up to 30 percent in its tests on long, multi-document inputs.
  • Give 3 to 5 examples from your own codebase, wrapped in <example> tags so the model can tell them apart from instructions.
  • State constraints as what to do (“raise RateLimitError“), not only what to avoid.
  • Ask for reasoning before code on tricky problems, and for one fenced block (or structured output) so the result is easy to parse.
  • Accept code only after it compiles, passes AST checks for your house rules, and passes behavior tests. Run that in a sandbox.

What this is based on: Anthropic’s prompting best practices for Claude (the general principles apply to other models too), and a harness that was run on two candidate outputs, shown below.

Figure 1. The prompt layout (your code, then examples, then constraints, then the task) and the acceptance gate that every generated module passes through before review. Diagram by CheatCoders.
Figure 1. The prompt layout (your code, then examples, then constraints, then the task) and the acceptance gate that every generated module passes through before review. Diagram by CheatCoders.

Pattern 1: Show your code first, ask last

The single biggest improvement is context: the error class you use, how middleware is written, how logging works. Anthropic’s guide recommends putting long documents near the top of the prompt, above the query, instructions and examples, and wrapping each in <document> tags with <source> and <document_content>. Choose files that show the patterns the new code must follow (a sibling module, the error definitions, a test), not the whole repository.

Earlier versions of this post recommended putting constraints first because “the model parses them first”. That isn’t what the model docs say, so the layout below puts the documents first and the task last.

Pattern 2: Tagged examples from your own code

Examples are one of the most reliable ways to steer format and style. The guidance is to make them relevant (mirror your real use case), diverse (cover edge cases, so the model doesn’t copy accidental details), and structured (each in <example> tags, all of them in <examples>), with 3 to 5 examples for best results. For code, a good example is a short, real function from your repository that shows the conventions, paired with the request that would have produced it.

Pattern 3: Constraints that say what to do

The same guide recommends telling the model what to do instead of what not to do. “Raise app.errors.RateLimitError when a request is refused” is clearer than “don’t use generic exceptions”, and it names the thing to use. Make constraints checkable, because the next section checks them. Avoid vague ones such as “production quality”.

python
"""Build a code-generation prompt: your code first, examples tagged, positive constraints, the task last."""
import tiktoken

enc = tiktoken.get_encoding("o200k_base")


def build(task: str, context_files: dict[str, str], examples: list[tuple[str, str]],
          constraints: list[str], budget: int = 6_000) -> str:
    docs, used = [], 0
    for i, (path, text) in enumerate(context_files.items(), 1):           # most relevant first
        block = f'<document index="{i}">\n<source>{path}</source>\n<document_content>\n{text}\n</document_content>\n</document>'
        n = len(enc.encode(block))
        if used + n > budget:
            break                                                          # drop whole files, never half a function
        docs.append(block); used += n
    ex = "\n".join(f"<example>\n<request>{q}</request>\n<code>\n{a}\n</code>\n</example>" for q, a in examples)
    rules = "\n".join(f"- {c}" for c in constraints)
    return (f"<documents>\n{chr(10).join(docs)}\n</documents>\n\n<examples>\n{ex}\n</examples>\n\n"
            f"<constraints>\n{rules}\n</constraints>\n\n<task>\n{task}\n</task>\n"
            "Return one Python module in a single fenced python code block and nothing else.")


CONSTRAINTS = [
    "Raise app.errors.RateLimitError(key, retry_after) when a request is refused.",
    "Log through the logger argument passed to the constructor.",
    "Take the current time as a parameter (now: float) so tests can control it.",
    "Annotate every function and method with types.",
    "Catch only the specific exceptions you expect, by name.",
]

The builder adds files whole, in order of relevance, until a token budget is reached, so the model never sees half a function. Then come the examples, the constraints and finally the task, with an explicit output format.

Pattern 4: Reasoning before code, for the hard parts

For concurrency, algorithms and debugging, ask the model to answer specific questions first: where state lives, what the race condition is, what happens when a dependency is down. Reviewing the reasoning is quicker than finding the flaw in the code, and wrong assumptions show up before they’re encoded. For debugging, ask for ranked hypotheses, each with a cheap way to test it. Many current models also have a built-in thinking mode (Anthropic now documents adaptive thinking for its latest models); check your model’s documentation for how that interacts with reasoning you ask for in the prompt.

Pattern 5: A role, and a format you can parse

Setting a role in the system prompt (“You are a senior Python reviewer for a payments service”) focuses tone and priorities, but it doesn’t replace context: a role without your files still produces generic code. For the output, ask for exactly one fenced block, or use structured outputs when you need several files with metadata (see structured outputs that compile).

One more instruction worth adding for agentic coding, from the same guide: ask for a general solution that works for all valid inputs, and say not to hard-code values to pass specific tests. Models sometimes optimize for the visible tests otherwise.

The acceptance check

A prompt raises the odds; a check makes the decision. This harness extracts the code block, parses it, applies house rules as AST checks (no print, no broad except, type hints everywhere, no direct clock reads), runs it, and tests behavior:

python
"""Accept generated code only if it compiles, follows the house rules (checked by AST) and passes behavior tests."""
import ast
import re
import types


def extract(reply: str) -> str:
    m = re.search(r"`{3}python\n(.*?)`{3}", reply, re.S)
    if not m:
        raise ValueError("no fenced python block in the reply")
    return m.group(1)


def house_rules(tree: ast.AST) -> list[str]:
    problems = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Call) and getattr(node.func, "id", "") == "print":
            problems.append(f"line {node.lineno}: print() instead of the logger")
        if isinstance(node, ast.ExceptHandler) and (node.type is None or getattr(node.type, "id", "") == "Exception"):
            problems.append(f"line {node.lineno}: broad except")
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
            if node.returns is None or any(a.annotation is None for a in node.args.args if a.arg != "self"):
                problems.append(f"line {node.lineno}: {node.name} is missing type hints")
        if isinstance(node, ast.Call) and getattr(getattr(node.func, "value", None), "id", "") == "time":
            problems.append(f"line {node.lineno}: reads the clock directly; take now as a parameter")
    return problems


def behavior(mod) -> list[str]:
    class Log:
        def __init__(self): self.lines = []
        def info(self, *a, **k): self.lines.append(a)
        warning = info
    b = mod.TokenBucket(capacity=2, refill_per_sec=1.0, logger=Log())
    errs = []
    if not (b.take("u1", now=0.0) and b.take("u1", now=0.0)):
        errs.append("first two requests should pass")
    try:
        b.take("u1", now=0.0); errs.append("third request should raise RateLimitError")
    except mod.RateLimitError as e:
        if not 0 < e.retry_after <= 1.0:
            errs.append(f"retry_after {e.retry_after} should be within (0, 1]")
    if not b.take("u1", now=1.0):
        errs.append("a token should refill after 1 second")
    if not b.take("u2", now=0.0):
        errs.append("keys must not share a bucket")
    return errs


def accept(reply: str, errors_module_src: str) -> dict:
    try:
        src = extract(reply)
        tree = ast.parse(src)
    except (ValueError, SyntaxError) as e:
        return {"accepted": False, "problems": [str(e)]}
    problems = house_rules(tree)
    mod = types.ModuleType("generated")
    exec(compile(errors_module_src, "app/errors.py", "exec"), mod.__dict__)    # provides RateLimitError
    try:
        exec(compile(src.replace("from app.errors import RateLimitError", ""), "<generated>", "exec"), mod.__dict__)
        problems += behavior(mod)
    except Exception as e:                                                     # the harness may catch broadly
        problems.append(f"runtime: {type(e).__name__}: {e}")
    return {"accepted": not problems, "problems": problems}
python
from prompt_builder import build, CONSTRAINTS
from acceptance import accept

FENCE = "`" * 3   # a code fence, spelled out so this listing doesn't contain one

ERRORS = '''class RateLimitError(Exception):
    def __init__(self, key: str, retry_after: float):
        super().__init__(f"{key} rate limited; retry after {retry_after:.2f}s")
        self.key, self.retry_after = key, retry_after
'''
prompt = build("Write TokenBucket(capacity, refill_per_sec, logger) with take(key, now) -> bool, one bucket per key.",
               {"app/errors.py": ERRORS, "app/middleware/auth.py": "def require_user(req, logger): ..."},
               [("a cache with expiry", "class TTLCache:\n    def get(self, key: str, now: float) -> str | None: ...")],
               CONSTRAINTS)
print(prompt[:120].replace("\n", " | "), "...", prompt[-160:].replace("\n", " | "))

tutorial = FENCE + '''python
import time
class TokenBucket:
    def __init__(self, capacity, refill_per_sec, logger):
        self.capacity, self.rate, self.buckets = capacity, refill_per_sec, {}
    def take(self, key, now=None):
        try:
            now = now or time.time()
            tokens, last = self.buckets.get(key, (self.capacity, now))
            tokens = min(self.capacity, tokens + (now - last) * self.rate)
            if tokens < 1:
                print("rate limited", key)
                return False
            self.buckets[key] = (tokens - 1, now)
            return True
        except:
            return True
''' + FENCE
constrained = FENCE + '''python
import logging

from app.errors import RateLimitError


class TokenBucket:
    def __init__(self, capacity: int, refill_per_sec: float, logger: logging.Logger) -> None:
        self.capacity, self.rate, self.log = capacity, refill_per_sec, logger
        self.buckets: dict[str, tuple[float, float]] = {}

    def take(self, key: str, now: float) -> bool:
        tokens, last = self.buckets.get(key, (float(self.capacity), now))
        tokens = min(float(self.capacity), tokens + (now - last) * self.rate)
        if tokens < 1.0:
            retry_after = (1.0 - tokens) / self.rate
            self.log.warning("rate limited", extra={"key": key, "retry_after": retry_after})
            raise RateLimitError(key, retry_after)
        self.buckets[key] = (tokens - 1.0, now)
        return True
''' + FENCE
for name, reply in (("tutorial-style", tutorial), ("constrained", constrained)):
    r = accept(reply, ERRORS)
    print(f"{name}: accepted={r['accepted']}")
    for p in r["problems"]:
        print("   -", p)
text
<documents> | <document index="1"> | <source>app/errors.py</source> | <document_content> | class RateLimitError(Exception): |      ... refill_per_sec, logger) with take(key, now) -> bool, one bucket per key. | </task> | Return one Python module in a single fenced python code block and nothing else.
rate limited u1
rate limited u1
tutorial-style: accepted=False
   - line 3: __init__ is missing type hints
   - line 5: take is missing type hints
   - line 15: broad except
   - line 7: reads the clock directly; take now as a parameter
   - line 11: print() instead of the logger
   - third request should raise RateLimitError
   - a token should refill after 1 second
constrained: accepted=True

The tutorial-style output fails seven checks. Two are worth a closer look. now = now or time.time() treats a time of 0.0 as “no time given”, so in the test it silently used the real clock, which is why the refill test failed: a bug that’s hard to see in review and obvious to a test. And the broad except: return True would have let every request through if anything went wrong. The constrained output passes. The two rate limited u1 lines at the top were printed by the tutorial version itself, which is the print problem in action.

Run this in a sandbox. The harness executes generated code. In CI that’s a container with no credentials and no network; never run it on a developer laptop with cloud credentials loaded.

Worked example: the retry loop

  1. First attempt: the model returns the tutorial version. The harness rejects it with seven problems.
  2. The problems go back to the model once, verbatim: “line 11: print() instead of the logger”, and so on.
  3. The second attempt passes. A human reviews the diff, which is now about logic, not style.
  4. If the second attempt had failed too, the harness stops and a human takes over. Endless retry loops cost tokens and hide a prompt that’s missing context.

Troubleshooting

SymptomLikely causeFix
Code ignores your conventionsNo examples or context filesAdd the sibling module and error definitions as documents
Model copies irrelevant details from an exampleExamples too similar to each otherUse 3 to 5 diverse examples, including an edge case
“Don’t do X” instructions are ignoredNegative phrasing with no alternativeSay what to do instead
Code passes tests but fails on new inputOptimized for the visible testsAsk for a general solution; add hidden tests
Can’t parse the replyProse around the codeAsk for one fenced block, or use structured outputs

FAQ

Do these patterns work with models other than Claude?

The principles (context, examples, clear constraints, checks) are general. Exact effects such as the “query at the end” improvement were measured by Anthropic on its models, so measure on yours.

How many context files should I include?

The few that show the patterns the new code needs. More isn’t better: it costs tokens and dilutes the signal.

Should I keep prompts in the repository?

Yes. Version them next to the acceptance checks, and review changes to either like code.

Is the AST check better than a linter?

Use both. A linter covers general style; AST checks encode rules specific to your codebase, like “use the injected logger”.

Official documentation

Last updated on October 10, 2026

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.