Reviewing AI-Generated Code: What to Actually Check (and What to Stop Doing)
Let's be honest. AI writes code faster than any of us ever will. That was never really the flex. The flex was always whether the code was correct, and that job hasn't disappeared just because Claude Code or Copilot did the typing. It's just moved. Nowadays it's not "did my junior dev cut a corner because it was 6pm on a Friday." It's "did the model confidently invent a method that doesn't exist and format it so cleanly I almost didn't check."
I've been doing this daily for close to a year now. Claude Code, Copilot, Playwright for the E2E layer, across FastAPI backends, Next.js frontends, Laravel, React Native. And the pattern repeats itself: code compiles, tests you asked it to write pass, demo works in the meeting, and three weeks later you find out it was swallowing an exception the whole time, or the retry logic never actually had a max attempt, or (my personal favorite) it hallucinated a config flag that hasn't existed since two versions ago. If you merge code you didn't actually read, you're the one who fed it to production.
Reviewing AI code is its own muscle. Here's what I actually check, with real examples, and the stuff I've learned to stop doing.
Why AI Code Fails Differently Than Human Code
Human code fails in predictable, tired-engineer ways. You can usually smell where someone cut a corner. AI code fails differently:
- It's confident regardless of correctness. It writes every line, wrong or right, with the same flat certainty.
- It looks correct, and that's a separate thing from being correct. Clean naming, idiomatic formatting, that's pattern-matching from training data, not proof anything actually works.
- It does exactly what you asked, not what you needed. Ask for "a function that retries the API call" and you'll get a retry. No backoff, no max attempts, no jitter, because you didn't say those words.
- It hallucinates. Library methods that don't exist, parameters in the wrong order, deprecated flags used like they're current.
None of this means stop using the tools. I use them every day and I'm not going back. It just means the review pass has to hunt for different things.
What I Actually Look For
1. Does it do what I meant, not just what I typed?
This is the biggest one. Put your original prompt next to the diff and check the gap. Say I ask for a retry on a flaky payment call:
# What the AI gave me for "add a retry to the M-Pesa STK push call"
def stk_push(payload):
for _ in range(5):
response = requests.post(MPESA_URL, json=payload)
if response.status_code == 200:
return response.json()
return NoneLooks fine at a glance. But there's no delay between retries, so you'll hammer the endpoint. There's no distinction between "network blip, retry" and "invalid payload, don't retry," so this will retry a 400 five times for nothing. And it returns None on total failure instead of raising, so somewhere downstream, something is going to treat None as a success and move on. I asked for a retry. I got zero error handling. That gap is exactly where AI code bites you.
Fixed version, with the actual intent captured:
import time
import requests
def stk_push(payload, max_attempts=3, backoff_seconds=2):
last_error = None
for attempt in range(max_attempts):
try:
response = requests.post(MPESA_URL, json=payload, timeout=10)
if response.status_code == 200:
return response.json()
if 400 <= response.status_code < 500:
# client error, retrying won't fix it
raise MpesaClientError(response.text)
except requests.exceptions.RequestException as e:
last_error = e
time.sleep(backoff_seconds * (attempt + 1))
raise MpesaTimeoutError(f"STK push failed after {max_attempts} attempts") from last_error2. Trace it. Don't skim it.
Fluent code is the easiest code to under-review, because your eyes glide over it. Force yourself to walk the null case, the empty list, the double-call, the concurrent call. AI is especially bad at edge cases nobody explicitly told it about.
3. Verify every API call and config value actually exists
This is the single highest-value check for AI code specifically. I've had models generate calls with a method signature from two major versions ago, and Laravel Eloquent relationship syntax that looked right but was quietly wrong. If you're unsure, check the docs. "It looks like real syntax" is a guess, not a confirmation.
4. Check what's missing. AI fails by omission.
Run through this list on every PR, whether it's yours or the AI's:
[ ] Input validation on new endpoints
[ ] Auth / permission checks on new routes
[ ] Rate limiting where it matters
[ ] Error is logged/raised, not swallowed
[ ] Empty / null / zero case handled
[ ] Idempotency on anything touching money or stateReal example. A webhook handler an AI wrote for me during the WhatsApp Embedded Signup work:
// Looked done. Wasn't.
app.post('/webhook', (req, res) => {
const { entry } = req.body;
processMessage(entry[0].changes[0].value);
res.sendStatus(200);
});There's no signature verification, so anyone can POST to this and it'll process it. There's no check that entry exists before indexing into it. And it 200s before confirming processMessage even succeeded. Meta will retry webhooks it doesn't get a fast 200 from, so you actually want the ack fast, but only after verifying the payload is real:
app.post('/webhook', (req, res) => {
if (!verifyMetaSignature(req)) {
return res.sendStatus(403);
}
const entry = req.body?.entry?.[0]?.changes?.[0]?.value;
if (!entry) {
return res.sendStatus(400);
}
res.sendStatus(200); // ack fast, Meta retries on timeout
processMessage(entry).catch(err => logger.error('webhook processing failed', err));
});5. Security, every single time
Look for: raw string-built SQL instead of parameterized queries, hardcoded secrets instead of env vars, missing auth on new routes, and dependencies quietly added in the diff. That last one especially matters. Check package.json / requirements.txt diffs, not just the code. A new package pulled in to solve one small function is a decision someone should have made on purpose.
# AI-generated, looks like a normal query
def get_user_orders(user_id):
query = f"SELECT * FROM orders WHERE user_id = {user_id}"
return db.execute(query)# What it should be
def get_user_orders(user_id):
query = "SELECT * FROM orders WHERE user_id = %s"
return db.execute(query, (user_id,))I've seen this exact pattern come out of a model that otherwise wrote clean FastAPI code. Confidence and correctness are two different things, full stop.
6. Test coverage that tests the requirement, not the implementation
If the same model wrote the function and the test, there's a real risk the test just checks "does the function do what the function does," which will happily pass even if the function is wrong. I run into this a lot with Playwright specs generated alongside a feature:
// AI wrote this alongside the feature. Technically passes, proves nothing.
test('checkout button works', async ({ page }) => {
await page.click('#checkout');
expect(await page.isVisible('#checkout')).toBe(true);
});That just confirms the button exists after clicking it. It says nothing about whether checkout actually happened. Rewrite it to assert on the real outcome:
test('checkout completes and shows order confirmation', async ({ page }) => {
await page.click('#checkout');
await expect(page.locator('#order-confirmation')).toContainText('Order confirmed');
await expect(page).toHaveURL(/\/orders\/\d+/);
});7. Does it fit the codebase, or does it just fit the prompt?
AI has no memory of your team's conventions unless you fed it context. If your error handling is normally a custom AppError class and the AI wrote raw throw new Error(), that's a real inconsistency, and inconsistency is a tax the next person (human or AI) pays every time they touch that file.
8. Complexity relative to the actual problem
Sometimes AI over-engineers: extra abstraction for a case that'll never happen. Sometimes it under-engineers: two lines for something that needed a real design decision. Flag both. More code is just more surface area, not more correctness.
What Not to Do
- Don't rubber-stamp because it reads clean. Clean formatting is the model's strongest suit and it tells you nothing about correctness. This is the number one way bad AI code gets merged.
- Don't treat "tests pass" as proof. Especially when the AI wrote both halves. Two artifacts agreeing with each other doesn't mean either one is right.
- Don't skip review because it's "just a small script." That's exactly where the hallucinated method call or the missing validation slips through, because nobody was really looking.
- Don't nitpick style it already matches. If you gave good context and it followed your conventions, don't waste review time renaming variables for taste. Spend that time on logic, security, and edge cases, because that's where the real risk lives.
- Don't let one AI review another AI's code and call it done. Fine as a sanity pass, but it's no substitute for a human on anything touching auth, payments, or production infra. Two models can share the exact same blind spot.
- Don't merge what you can't explain. If you can't say out loud what a block does and why, you're guessing, not reviewing. AI code makes this trap easy to fall into because it's rarely messy enough to force you to slow down.
- Don't leave your review comments bare. "Fix this" teaches nothing. "This retries on a 400 which will never succeed, only retry on 5xx or network errors" actually prevents the next one.
The Short Version
Review AI-generated code the way you'd review a brilliant, extremely confident junior who's read every doc but shipped nothing to real users yet. Capable of very good work, zero instinct yet for what actually breaks in production. Check for what's missing more than what's there. Verify anything that looks like a fact: an API call, a config value, a claim about behavior. And don't let clean formatting stand in for actually tracing the logic. The tools have gotten very good at writing code that looks reviewed. That's exactly why the real review still has to happen.