The Problem With Vague Prompts
Most developers hit the same wall within a week of using a coding LLM. The model produces code that looks plausible, compiles, and is completely wrong for the actual problem. You paste the output in, it breaks in a non-obvious way, and you spend 20 minutes figuring out why before rewriting the whole thing yourself.
The standard advice is "write better prompts," which is not advice at all. This post is about what that actually means in practice when the output you want is working, production-quality code.
The core issue is that LLMs are trained on a massive distribution of code, most of which is mediocre. When you under-specify a prompt, the model picks the most statistically likely completion, which is often tutorial-grade code written for the most common case. If your problem is slightly non-standard, that completion will miss important constraints, reach for the wrong abstraction, or silently ignore error cases.
The techniques here work across the major coding LLMs in 2025 and 2026: Claude 3.5 Sonnet, Claude 3.7 Sonnet, GPT-4o, and Gemini 1.5 Pro. They apply whether you are using a chat interface, Claude Code, Cursor, or your own API integration. The examples use TypeScript and Python because those are the languages where the patterns are clearest, but the principles transfer.
Specify the Constraint Space, Not Just the Goal
The single biggest improvement in prompt quality comes from describing what the code must NOT do, or what it must avoid, alongside what it must do.
Consider the difference between these two prompts:
Write a function that fetches user data from an API and caches the result.versus:
Write a TypeScript function that fetches user data from a REST API and caches
the result in memory. Constraints:
- No external caching libraries (no redis, no node-cache)
- Must be safe to call concurrently (no thundering herd on cache miss)
- Cache TTL is 5 minutes, configurable per call-site
- Return type must be typed, not `any`
- Errors from the fetch must propagate, not be swallowedThe second prompt produces something like this:
type CacheEntry<T> = {
value: T;
expiresAt: number;
};
const cache = new Map<string, CacheEntry<unknown>>();
const inFlight = new Map<string, Promise<unknown>>();
async function fetchWithCache<T>(
key: string,
fetcher: () => Promise<T>,
ttlMs: number = 5 * 60 * 1000
): Promise<T> {
const now = Date.now();
const entry = cache.get(key) as CacheEntry<T> | undefined;
if (entry && entry.expiresAt > now) {
return entry.value;
}
const existing = inFlight.get(key) as Promise<T> | undefined;
if (existing) {
return existing;
}
const promise = fetcher().then((value) => {
cache.set(key, { value, expiresAt: now + ttlMs });
inFlight.delete(key);
return value;
}).catch((err) => {
inFlight.delete(key);
throw err;
});
inFlight.set(key, promise as Promise<unknown>);
return promise;
}The first prompt produces something that either uses node-cache, ignores concurrent calls entirely, or returns any. The constraint list forces the model to make deliberate choices rather than defaulting to the median training example.
Notice the constraints are short and testable. Vague quality signals like "production-ready" or "clean code" do not help because the model has no grounded definition for them. "No external libraries" and "errors must propagate" are checkable properties.
Give the Model the Surrounding Context
LLMs generate the statistically most likely continuation of your prompt. When you only include the function you want, the model fills in the missing context from its prior, which may not match your codebase at all.
If you are working inside a Next.js 15 App Router project that uses Zod for validation and Prisma as the ORM, you need to say that. The model will otherwise reach for older patterns, use a different validation approach, or structure the code in a way that does not compose with your existing files.
A practical pattern is to include a short "context block" before the actual instruction:
Context:
- Next.js 15 App Router, TypeScript strict mode
- Prisma 5 with PostgreSQL
- Zod for all input validation
- Server Actions for mutations (no dedicated API routes for this feature)
- We do NOT use try/catch at the action level; errors bubble to error.tsx
Task:
Write a Server Action that creates a new job application record. Input fields:
companyName (string, max 120 chars), jobTitle (string, max 120 chars),
appliedAt (ISO date string), status (enum: "applied" | "interview" | "offer" | "rejected").This produces an action that imports from @/lib/db, uses z.object() for validation, calls prisma.jobApplication.create(), and does not wrap everything in a silent try/catch. Without the context block, you get a generic API route handler using fetch and JSON.parse.
You do not need to paste the entire codebase. A 5-line summary of the stack and the one or two patterns you care about is enough.
Structured Output Prompts for Complex Code Tasks
When the task involves multiple files, multiple functions, or a decision tree, free-form prose prompts degrade fast. The model starts guessing at scope and tends to either over-generate (produces a whole module when you wanted one function) or under-generate (produces a stub).
One reliable technique is to ask the model to return structured output first, then expand it.
I need a data pipeline that reads CSV rows, validates them, and writes valid
rows to Postgres and invalid rows to a dead-letter file.
Before writing any code, output a JSON plan in this format:
{
"modules": [
{ "filename": "string", "purpose": "string", "exports": ["string"] }
],
"dataFlow": "string (one sentence)",
"externalDeps": ["string"]
}Once the model outputs the plan, you can correct scope ("we do not need a separate validator module, inline it") before spending tokens on the implementation. This prevents the situation where you get 200 lines of code you cannot use because the model made the wrong structural choice at the start.
For Python data pipelines this looks like:
import csv
import psycopg2
from pathlib import Path
from dataclasses import dataclass
from typing import Iterator
@dataclass
class Row:
name: str
amount: float
reference: str
def parse_row(raw: dict[str, str]) -> Row:
"""Raises ValueError if validation fails."""
name = raw.get("name", "").strip()
if not name:
raise ValueError("name is empty")
try:
amount = float(raw["amount"])
except (KeyError, ValueError):
raise ValueError(f"invalid amount: {raw.get('amount')!r}")
reference = raw.get("reference", "").strip()
if not reference:
raise ValueError("reference is empty")
return Row(name=name, amount=amount, reference=reference)
def process(
source: Path,
dead_letter: Path,
conn: psycopg2.extensions.connection,
) -> tuple[int, int]:
ok = 0
bad = 0
with (
source.open(newline="") as f,
dead_letter.open("w", newline="") as dl,
conn.cursor() as cur,
):
reader = csv.DictReader(f)
writer = csv.writer(dl)
for raw in reader:
try:
row = parse_row(raw)
cur.execute(
"INSERT INTO transactions (name, amount, reference) VALUES (%s, %s, %s)",
(row.name, row.amount, row.reference),
)
ok += 1
except ValueError as e:
writer.writerow([*raw.values(), str(e)])
bad += 1
conn.commit()
return ok, badCompare this to what you get from "write a CSV to Postgres pipeline in Python": usually a monolithic script with no separation between parsing and writing, no dead-letter handling, and except Exception: pass somewhere in the loop.
Role Prompts and Personas: When They Help and When They Do Not
You will see a lot of advice about giving the model a role ("You are a senior TypeScript engineer who values clean code"). This is sometimes useful but often oversold.
Role prompts help in two specific situations. First, when you want the model to take a particular stance, such as "act as a code reviewer and point out what a PR reviewer would reject." Second, when you want it to match a specific style, such as "write this in the style of the Rust book, with explained concepts before code."
Role prompts do not help for correctness. Telling the model it is a "world-class expert" does not make its knowledge more accurate. If it does not know a specific API signature, a role prompt will not fix that. You still need to supply the ground truth for anything version-specific or domain-specific.
A role prompt that does work in practice:
You are a code reviewer doing a final pass before merging. Your job is to find:
1. Any case where an error is caught and silently dropped
2. Any `any` type that is not justified by a comment
3. Any place where a promise is not awaited
Review this TypeScript file and output a numbered list. If you find nothing,
say "no issues found." Do not suggest style changes.This is effective because the role narrows the model's output distribution to a specific review task and tells it to ignore everything else. Without the role framing, the model gives you a broad code review that mixes critical issues with stylistic opinions.
The Gotcha: Models Confidently Fill Gaps With Plausible Lies
Here is the real lesson I learned the hard way. LLMs do not say "I do not know this API method." They say something that sounds right. This is particularly bad for:
- Library versions (what was valid in v4 is removed in v5)
- Internal SDK methods that have changed between releases
- Third-party service APIs that the model has limited training data for
I once had Claude generate a Stripe webhook handler that used stripe.webhooks.constructEventAsync. That method does not exist in the stripe Node SDK. The correct method is stripe.webhooks.constructEvent. The code looked right, passed a quick read, and only blew up at runtime.
The mitigation is not to trust the model on any method or API you cannot immediately verify against real docs. A pattern that helps:
Write a Stripe webhook handler for the `payment_intent.succeeded` event.
Use only methods from the official stripe-node v16 SDK. If you are not certain
a method exists, write a comment `// VERIFY: <method name>` instead of using it.This produces code with explicit uncertainty markers rather than confident hallucinations. You then grep for VERIFY: and check those spots against the actual SDK. It is a small discipline that saves significant debugging time.
The same pattern works for any external API or framework:
Iterative Refinement vs. One-Shot Prompting
Most people treat coding LLMs as one-shot tools. You write the prompt, you get the code, you either use it or start over. This wastes the model's context window and your time.
A more productive flow is to use the first output as a draft and refine it with targeted follow-up prompts. The key is that each follow-up prompt should address exactly one issue, not ask for a general improvement.
Bad follow-up:
This is not quite right. Can you improve it?Good follow-up:
The current implementation re-opens the database connection on every call.
Change it to accept a connection pool as a parameter instead of creating
its own connection. Do not change anything else.The instruction "do not change anything else" is load-bearing. Without it, the model often refactors the whole function when you only wanted one change, and you end up reviewing twice as much code.
For complex functions you can also ask the model to narrate its plan before writing the implementation:
Before writing the code, explain in 3 to 5 sentences what approach you will take
and what the main edge cases are. Then write the code.The narration surfaces wrong assumptions before they get baked into 50 lines of code. If the model's plan mentions handling a case you know cannot happen, you correct it upfront and get a cleaner output.
Few-Shot Examples for Style and Pattern Matching
If you have an existing codebase with an established pattern, the fastest way to get matching output is to include one or two examples of that pattern in the prompt. This is few-shot prompting applied to code style.
Say your project uses a specific error-handling pattern where every async function returns a Result<T, E> discriminated union instead of throwing:
// Example of the pattern we use in this codebase:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function getUser(id: string): Promise<Result<User>> {
try {
const user = await db.user.findUniqueOrThrow({ where: { id } });
return { ok: true, value: user };
} catch (err) {
return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
}
}
// Now write a similar function: getPost(id: string) that fetches a Post record
// using the same Result pattern. Post has fields: id, title, content, authorId.The one example is enough for the model to pick up the union structure, the naming conventions, and the error wrapping. Without it, you get a function that throws or uses a completely different shape.
This is faster than describing the pattern in prose, because the model matches on code syntax more reliably than on English descriptions of code patterns.
Temperature, Models, and When to Switch
Most chat interfaces hide temperature settings, but if you are calling the API directly, temperature matters for code generation. The default temperature on most models is 0.7 to 1.0, which is calibrated for creative tasks. For code, especially for correctness-critical logic, a lower temperature (0.2 to 0.4) produces more deterministic and correct output.
curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "claude-sonnet-4-5",
"max_tokens": 2048,
"temperature": 0.2,
"messages": [{
"role": "user",
"content": "Write a TypeScript function that validates a UK National Insurance number..."
}]
}'Beyond temperature, model choice matters for different subtasks:
For high-correctness logic (parsing, state machines, validation), use the most capable model you have access to and lower temperature. The cost difference between Sonnet and a smaller model is irrelevant if the smaller model produces plausible but wrong code that takes an hour to debug.
For boilerplate generation (React components, CRUD endpoints, test stubs), a faster and cheaper model works fine. The output is formulaic and easy to verify.
For code review and bug hunting, models with larger context windows are better because you can include more of the codebase. Claude 3.7 Sonnet's 200k context window is genuinely useful here.
How to Apply This Day to Day
None of the techniques above require a new tool or a plugin. They work in any coding LLM interface. Here is a practical summary of when to apply each:
For a net-new function or module: write a constraint list, include a 5-line stack context block, and ask for a plan before implementation on anything that spans more than one file.
For matching existing code style: paste one example of the pattern and say "write X using the same pattern."
For external APIs or library methods you are less certain about: add the // VERIFY: instruction and check those spots against real docs before committing.
For iterative refinement: address one issue per follow-up prompt and explicitly say "do not change anything else."
For code review tasks: give the model a narrowly scoped reviewer role with a specific checklist rather than asking for a general review.
For API usage: if you have control over temperature, drop it to 0.2 to 0.4 for correctness-critical code.
The most important habit is verifying the output at the boundary. Do not trust method names, do not trust API signatures from memory, and do not trust that the model's approach is the only one. Read what it produces and check the parts that are specific to external systems against the actual source of truth.
Key Takeaways
- Constraint lists outperform vague quality requests. Tell the model what to avoid, not just what to do.
- Context blocks (stack, patterns, project conventions) are worth 5 lines of your prompt for significantly better structural matching.
- Plan before implement on multi-file or structurally complex tasks. Correct the scope before the code.
- Few-shot examples beat prose descriptions for style matching. One good example is worth a paragraph of explanation.
- The
// VERIFY:pattern surfaces hallucinated API calls before they reach runtime. Use it for any method or parameter you cannot immediately confirm. - Narrow role prompts help for review tasks; they do not help for correctness.
- One issue per follow-up prompt with "do not change anything else" prevents unnecessary churn.
- Lower temperature (0.2 to 0.4) for correctness-critical logic when you have API access.
- Read the output, especially at external boundaries. The model is confident whether it is right or not.