Section650

Writing

TopicAI
Reading10 min

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 None above if value is None.
  • New helper functions and files nobody asked for, often in a new utils/ or helpers/ 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:

Version 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:

Version A · view
# 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 queryset

It 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_STATUSES copies 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=pending and 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 Exception blocks 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

Version B · view
# 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 qs

Six 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:

Version B · stricter, if the team wants a 400
        if status:
            if status not in Opportunity.Status.values:
                raise ValidationError({"status": "Unknown status."})
            qs = qs.filter(status=status)
Fig. 702-1 The same request, two implementations
Version A: the long oneVersion B: the short one
Non-blank lines of code536
New files1, in a new utils/filters/ packageNone
Classes3, for one filter0
Where valid statuses come fromA copied list that can drift from the modelThe model and the database
?status=openOpen opportunitiesOpen opportunities
?status=pending (not a real status)Every opportunity, as if the filter workedAn empty list
A database errorLogged, then an unfiltered listA 500 error someone will see
Both versions return open opportunities for ?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 status column 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.
If the project already uses django-filter
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:

The test that came with it
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()) == 1

It 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:

The test that was missing
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.

Fig. 702-2 Four things I can do with a generated change
DecisionWhen I make it
AcceptIt does what was asked, follows the project’s conventions, and I can explain every line.
ModifyThe approach is right but details are off: a name, a missed case, an error that should be loud.
SimplifyIt works but it’s heavier than the problem. Delete layers until it’s the size of the request.
RejectIt 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:

  1. I write down what done looks like before I prompt: the behavior, the edge cases, and what’s out of scope.
  2. I give it the context it can’t guess. That means the relevant files, the libraries we already use, and how we handle errors.
  3. I keep each change small enough to review in one sitting. One task, one pull request.
  4. I read every line. If I can’t explain a line, it doesn’t get merged.
  5. I ask what can be deleted. “Can this be simpler?” is my most useful follow-up prompt.
  6. I make sure the tests cover the case that would embarrass me, not only the happy path.
  7. I refactor when the first answer works but doesn’t fit the codebase.
  8. 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.

Talk about how your team uses AI How I run engineering