Writing
I Can Recognize AI-Generated Code. Here’s Why.
I use AI coding tools a lot, and I keep seeing the same habits in what they write. Recognizing them is useful. Whether the author understands the code matters more.
By Ka Lun Chan · Engineering leadership · AI / Engineering judgment
The patterns I keep seeing
I’ve been spending a lot of time with AI coding tools, mostly Claude Code and Codex. The more I use them, the more I notice the same habits in the code they write. Sometimes I read a pull request and think: an AI did most of this.
I’m not complaining. I use these tools all the time and they make me faster. What I care about is whether the person who opened the pull request understands what’s in it, and whether the code fits the problem it was meant to solve.
These are the habits I notice most:
- Abstractions with one use. A base class, a strategy and a factory, all for a single case.
- Defensive checks for things that can’t happen, like checking that a URL query parameter is a string. It always is.
- The same validation or error handling in three layers, each one catching what the layer below already caught.
- A simple problem solved with a general framework, in case the problem grows. It usually doesn’t grow in that direction.
- Generic names:
data,result,handler,process_item,utils. - Comments that repeat the next line.
# Check if value is Noneaboveif value is None. - New helper functions and files nobody asked for, often in a new
utils/orhelpers/folder. - Features beyond the request: caching, retries, configuration options, a second endpoint “for completeness.”
None of these prove who wrote the code. People wrote code like this long before AI, and I’ve written some of it myself. A pattern is a reason to ask the author a few questions. That’s all it is.
One small request, two answers
Here’s an example I wrote for this article. It’s a composite of habits I’ve seen, not a pull request from a client or a teammate. The project is a Django REST Framework API that lists contract opportunities for the signed-in user. The request is one sentence:
Add a status filter, so /api/opportunities/?status=open returns only open opportunities.
Version A
This is the kind of answer I get when I give an assistant that one sentence and nothing else. It creates a new file:
# opportunities/utils/filters/status_filter.py
import logging
from abc import ABC, abstractmethod
from typing import Any, Optional
from django.db.models import QuerySet
logger = logging.getLogger(__name__)
class BaseFilterStrategy(ABC):
"""Abstract base class for filter strategies."""
@abstractmethod
def apply(self, queryset: QuerySet, value: Any) -> QuerySet:
"""Apply the filter to the queryset."""
pass
class StatusFilterStrategy(BaseFilterStrategy):
"""Filter strategy for filtering by status."""
VALID_STATUSES = ["open", "closed", "awarded"]
def apply(self, queryset: QuerySet, value: Any) -> QuerySet:
# Check if value is None
if value is None:
return queryset
# Check if value is a string
if not isinstance(value, str):
logger.warning("Invalid status type: %s", type(value))
return queryset
# Normalize the value
normalized_value = value.strip().lower()
# Validate the status
if normalized_value not in self.VALID_STATUSES:
logger.warning("Invalid status value: %s", value)
return queryset
try:
return queryset.filter(status=normalized_value)
except Exception as e:
logger.error("Error filtering by status: %s", e)
return queryset
class FilterStrategyFactory:
"""Factory for creating filter strategies."""
_strategies = {"status": StatusFilterStrategy}
@classmethod
def get_strategy(cls, filter_name: str) -> Optional[BaseFilterStrategy]:
strategy_class = cls._strategies.get(filter_name)
if strategy_class is None:
return None
return strategy_class()And it changes the view to use it:
# opportunities/views.py
def get_queryset(self):
queryset = Opportunity.objects.filter(owner=self.request.user)
try:
status_value = self.request.query_params.get("status", None)
if status_value is not None:
strategy = FilterStrategyFactory.get_strategy("status")
if strategy is not None:
queryset = strategy.apply(queryset, status_value)
except Exception as e:
logger.error("Unexpected error applying filters: %s", e)
return querysetIt looks thorough. It has type hints, docstrings, logging and error handling. Read it closely and the problems show up:
- An abstract base class, a strategy and a factory exist to support exactly one filter. If a second filter ever arrives, that’s the time to look for a pattern.
isinstance(value, str)guards against something that can’t happen. Query parameters are always strings.VALID_STATUSEScopies the model’s choices by hand. When someone adds a “cancelled” status to the model, this list won’t know.- An unknown status returns the whole list. Ask for
?status=pendingand you get every opportunity, so the screen looks like the filter worked. This is the real bug, and it’s the one most likely to reach a user. - Two
except Exceptionblocks turn a database error into a log line and an unfiltered result. Errors that should be loud are now quiet. - Every ordinary typo in a URL writes a warning to the logs.
- Half the comments say what the next line says.
Version B
# opportunities/views.py
def get_queryset(self):
qs = Opportunity.objects.filter(owner=self.request.user)
status = self.request.query_params.get("status")
if status:
qs = qs.filter(status=status)
return qsSix lines, no new files. An unknown status returns an empty list, which is the honest answer. A database error raises, so monitoring sees it. The valid statuses live where they always lived: on the model.
If the team prefers a clear error for an unknown status, that’s two more lines, and the list of statuses still comes from the model:
if status:
if status not in Opportunity.Status.values:
raise ValidationError({"status": "Unknown status."})
qs = qs.filter(status=status)| Version A: the long one | Version B: the short one | |
|---|---|---|
| Non-blank lines of code | 53 | 6 |
| New files | 1, in a new utils/filters/ package | None |
| Classes | 3, for one filter | 0 |
| Where valid statuses come from | A copied list that can drift from the model | The model and the database |
| ?status=open | Open opportunities | Open opportunities |
| ?status=pending (not a real status) | Every opportunity, as if the filter worked | An empty list |
| A database error | Logged, then an unfiltered list | A 500 error someone will see |
?status=open, so a quick manual check passes either way. They differ on bad input and on failures, where version A hides the problem.More code doesn’t mean better code
AI can write hundreds of lines in the time it takes me to read the ticket. That speed makes long answers feel free. They aren’t. Every line is something a teammate has to read in review, understand during an incident, and keep in mind the next time they change something nearby.
Version A would also make the next change harder. A developer adding a second filter now has to learn a strategy pattern and a factory first, and will probably copy them. Code tends to grow the way it started.
I’d rather review six lines I can understand in ten seconds than fifty that look careful. Simple code is easier to test and to change. When something breaks late at night, it’s much easier to fix. Small pull requests make the difference obvious, which is one reason I push for smaller user stories and pull requests.
What the AI couldn’t see
The assistant saw one file and one sentence. It solved the problem it was given. It had no way to know the rest:
- The project might already use django-filter, where the whole feature is a setting on the view. The team’s convention would have made this a three-line change.
- The team might return a 400 error for bad input everywhere else, so silently ignoring a bad value breaks a rule users of the API already rely on.
- The list is scoped to the signed-in user for a security reason. Version A kept that line, but a “cleanup” in the same style could easily lose it.
- Whether the
statuscolumn has a database index decides whether this filter is fast on a large table. - Someone has to maintain the new
utils/filters/package long after the ticket is closed.
class OpportunityList(generics.ListAPIView):
filter_backends = [DjangoFilterBackend]
filterset_fields = ["status"]That context is my job to provide. When I don’t give it, the AI fills the gap with guesses that look like best practices. The same tool, given the conventions and the relevant files, usually writes something close to version B.
It passed the tests
Version A comes with a test, and the test passes:
def test_filter_by_status(api_client, user):
OpportunityFactory(owner=user, status="open")
OpportunityFactory(owner=user, status="closed")
response = api_client.get("/api/opportunities/?status=open")
assert response.status_code == 200
assert len(response.json()) == 1It would pass with the bug in place, because it never asks for a status that doesn’t exist. This is the test that catches it. Version A returns every opportunity here and fails:
def test_unknown_status_returns_nothing(api_client, user):
OpportunityFactory(owner=user, status="open")
response = api_client.get("/api/opportunities/?status=pending")
assert response.json() == []Both tests assume the usual pytest fixtures for a signed-in client and a factory for test records.
AI-generated code is convincing. It’s formatted, it has docstrings, and it often has tests. None of that tells you it handles the cases that matter. When I merge a pull request, the code is mine, whoever typed it. If it breaks in production, “the AI wrote it” isn’t an answer anyone on call wants to hear. The person who opens the pull request should be able to explain every line, and the reviewer should ask them to.
Accept, modify, simplify or reject
As writing code gets faster, the decision about what to keep becomes most of the work. Every generated change ends in one of four places.
| Decision | When I make it |
|---|---|
| Accept | It does what was asked, follows the project’s conventions, and I can explain every line. |
| Modify | The approach is right but details are off: a name, a missed case, an error that should be loud. |
| Simplify | It works but it’s heavier than the problem. Delete layers until it’s the size of the request. |
| Reject | It solves a different problem, or I can’t tell why it works. Add context and try again, or write it myself. |
Knowing which one applies takes experience. You have to have seen enough systems to know what this one needs, and enough incidents to know which errors should be loud. I wrote about the cost side of this, including what it looks like when nobody makes these calls, in the hidden cost of AI coding.
How I use AI coding tools
This is roughly how I work, on my own projects and on client code:
- I write down what done looks like before I prompt: the behavior, the edge cases, and what’s out of scope.
- I give it the context it can’t guess. That means the relevant files, the libraries we already use, and how we handle errors.
- I keep each change small enough to review in one sitting. One task, one pull request.
- I read every line. If I can’t explain a line, it doesn’t get merged.
- I ask what can be deleted. “Can this be simpler?” is my most useful follow-up prompt.
- I make sure the tests cover the case that would embarrass me, not only the happy path.
- I refactor when the first answer works but doesn’t fit the codebase.
- When it goes in circles, I stop and write the code myself.
Short answers
Can you tell if code was written by AI?
Not reliably. AI-generated code often has recognizable habits, like extra abstractions, defensive checks for things that can’t happen and comments that repeat the code. People write code like that too, so the patterns are a reason to ask questions, not proof of who wrote it.
What are common signs of AI-generated code?
Abstractions with only one use, repeated validation and broad error handling, generic names like data, result and handler, comments that restate the next line, new helper files nobody asked for, and features beyond what the task requested.
Is it bad to use AI to write code?
No. AI coding tools can save a lot of time. The problem is merging code you don’t understand. Whoever opens the pull request is responsible for explaining it, testing it and maintaining it, whoever or whatever typed it.
How should you review AI-generated code?
The same way as any code, with extra attention to size. Check it against the actual request, ask what can be deleted, look for errors that get swallowed instead of reported, compare it with the project’s existing conventions, and make sure the tests cover the case that would break it.
I don’t care who wrote it
I care whether the code is correct, understandable, maintainable and secure, and whether it solves the problem we actually have.
AI is a tool, and a good one. Engineering judgment is still what decides what gets merged.