
Introduction
AI coding tools have become effective at writing Python code that works. Tools such as Claude Code, GitHub Copilot, and Cursor’s agentic mode can build entire applications and implement complex algorithms in minutes. But AI-generated code is often difficult to maintain, and the costs surface later — typically when a developer has to re-read a bloated function weeks after it was generated just to understand what it does.
The core problem is not that AI writes bad code, although that happens. It is that AI optimizes for “works now” and for satisfying the immediate request, not for the long-term maintainability of the codebase. The practices below shift that balance.
Avoiding the blank canvas trap
The biggest mistake is asking AI to start from scratch. AI agents perform best under constraints, so the project fundamentals should be set up by hand first: the directory structure, the core libraries, the test framework, and a few working example features that set the tone. For an API project, that means implementing one full endpoint manually with all the desired patterns — dependency injection, error handling, database access, validation — as a reference implementation.
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
router = APIRouter()
# Assume get_db and User model are defined elsewhere
async def get_user(user_id: int, db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return userWhen the AI sees this pattern, it learns how dependencies are handled, how the database is queried, and how missing records are treated. Architectural decisions should not be delegated to the model.
Letting the type system do the heavy lifting
Python’s dynamic typing becomes a liability when an AI writes the code. Type hints should be a mandatory guardrail: with annotations on every function signature and mypy running in strict mode, the model cannot take shortcuts, return ambiguous types, or accept parameters that might be strings or lists. Strict typing also forces better design — a function that accepts a typed UserCreateRequest model has exactly one interpretation, where a bare dict invites assumptions.
# This constrains AI to write correct code
from pydantic import BaseModel, EmailStr
class UserCreateRequest(BaseModel):
name: str
email: EmailStr
age: int
class UserResponse(BaseModel):
id: int
name: str
email: EmailStr
def process_user(data: UserCreateRequest) -> UserResponse:
pass
# Rather than this
def process_user(data: dict) -> dict:
passLibraries that enforce contracts help further: SQLAlchemy 2.0+ with typed models and FastAPI with response models are strong options. When generated code fails type checking, the agent iterates until it passes — an automatic feedback loop that produces better code than prompt engineering alone.
Documentation that guides the AI
AI agents need documentation they will actually use: a single, concise guidelines file such as CLAUDE.md or AGENTS.md at the project root. It should focus on what is unique to the project rather than generic best practices, and specify the project structure, which libraries to use for common tasks, typical patterns to follow (pointing at example files), explicitly forbidden patterns, and test requirements.
# Project Guidelines
## Structure
/src/api - FastAPI routers
/src/services - business logic
/src/models - SQLAlchemy models
/src/schemas - Pydantic models
## Patterns
- All services inherit from BaseService (see src/services/base.py)
- All database access goes through repository pattern (see src/repositories/)
- Use dependency injection for all external dependencies
## Standards
- Type hints on all functions
- Docstrings using Google style
- Functions under 50 lines
- Run `mypy --strict` and `ruff check` before committing
## Never
- No bare except clauses
- No type: ignore comments
- No mutable default arguments
- No global stateSpecificity is the point. “Follow best practices” achieves nothing; pointing to the exact file that demonstrates the desired error-handling pattern does.
Prompts that point to examples
Generic prompts generate generic code. A maintainable-code prompt references the existing codebase: implement JWT authentication in a named file, follow the structure of an existing service, use the hashing library already in requirements, add dependencies following an existing pattern, create schemas similar to an existing one, and add pytest tests using the fixtures already defined. Each instruction anchors the model to a file or pattern that already exists — the AI is implementing a feature, not inventing an architecture. Generated code should then be reviewed specifically for pattern conformance, with discrepancies pointed out and corrected.
Planning before implementation
Speed becomes a liability when it outruns structure. Requesting an implementation plan before any code is written forces the model to think through dependencies and gives the developer a chance to catch architectural problems — circular dependencies, redundant services — early. A useful plan specifies which files will be created or modified, the dependencies between components, which existing patterns will be followed, and what tests are needed. The plan should be reviewed like a design document; fixing a bad plan is far cheaper than fixing bad code.
Tests that actually test
AI writes tests quickly, but by default it tests only the happy path — verifying the code works precisely in the cases where tests are least needed. Requirements should be explicit: happy-path tests, validation-error tests for invalid input, edge-case tests for empty values, None, and boundary conditions, and error-handling tests for database and external-service failures. Existing high-quality test files serve as the example; where none exist yet, writing a few by hand first pays off.
Systematic validation
After generation, code should pass a checklist rather than a smoke test: strict mypy, conformance to existing patterns, functions under roughly 50 lines, edge-case and error coverage, type hints everywhere, correct use of the specified libraries. As much as possible belongs in automation — pre-commit hooks running mypy, ruff, and pytest mean failing code simply does not get committed. The rest comes from review experience: recurring AI anti-patterns include functions that do too much, error handling that swallows exceptions, and validation logic mixed into business logic.
The workflow in practice
Assembled, the workflow looks like this: set up structure and libraries, write example features, create the guidelines file, then prompt for a new feature with references to the examples. The AI plans; the plan is reviewed and approved; the AI implements; type checks and tests run; the code is reviewed against the established patterns and committed. A feature that would take an hour by hand may take fifteen minutes — and each completed feature gives the AI another example to learn from, so consistency compounds over time.
Limitations and what to watch
These practices reduce, but do not eliminate, the maintenance burden of AI-generated code. Strict typing and tests catch structural errors, not subtle logic mistakes, so human review remains the final gate — and review quality degrades when volume grows faster than reviewer attention, a dynamic worth monitoring on any team adopting agentic tools. Guardrails also carry overhead: strict mypy and heavy pre-commit hooks slow iteration on prototypes where maintainability may not matter yet. Finally, tool behavior changes quickly as agents and models evolve, so team guidelines files deserve periodic review. The broader economics of agentic coding at scale are illustrated in this account of Claude Code adoption inside Microsoft, and general agent-design trade-offs in Anthropic’s guide to building effective agents.