AI Coding Rules for Python and Django Projects
Practical AI coding rules for Python and Django projects. Covers project structure, views, models, serializers, testing, type hints, and a starter rules file.
AI Coding Rules for Python and Django Projects
Python's flexibility is a double-edged sword when working with AI coding tools. There are multiple valid ways to structure a Django project, handle imports, write views, and manage database queries. Without explicit rules, AI assistants will pick whichever pattern they saw most often in training data, which might be a tutorial-quality example from 2019 or a pattern from Flask when you're using Django.
Django adds another layer of complexity. Class-based views vs function-based views. ModelSerializer vs plain Serializer. Raw SQL vs the ORM vs Manager methods. The AI has to make a choice every time, and without rules, it makes a different choice every time.
This guide covers the rules you need for Python and Django projects across every layer: project structure, views, models, serializers, testing, and deployment. Each rule is ready to copy into your Cursor rules file, CLAUDE.md, or Windsurf rules.
Python version and tooling
Start every rules file with your Python version and tooling. AI models will default to older syntax and tooling without this.
## Python environment
- Python 3.12+
- Package manager: uv (not pip, not poetry)
- Formatter: ruff format (not black)
- Linter: ruff check (not flake8, not pylint)
- Type checker: mypy with strict mode
- Test runner: pytest (not unittest)
- Django 5.2+ (LTS)
This single block prevents the most common mistakes: generating requirements.txt instead of pyproject.toml, using f-strings that require 3.12 features alongside syntax that targets 3.8, and reaching for deprecated Django APIs.
Project structure
Django's default startproject layout works for tutorials but not for production. If you've customized your structure, tell the AI.
## Project structure
- Config module: config/ (not the default project-named folder)
- config/settings/base.py, config/settings/local.py, config/settings/production.py
- config/urls.py, config/wsgi.py, config/asgi.py
- Apps live in src/apps/ (not at the project root)
- Each app contains: models.py, views.py, urls.py, serializers.py, tests/, admin.py
- Shared utilities: src/common/
- Templates: templates/[app_name]/
- Static files: static/
Do NOT put app code at the project root level.
Do NOT use a single settings.py file, always use the split settings pattern.
Without structure rules, AI tools will scatter new files in random locations. They'll create a new app at the project root when all your other apps are nested under src/apps/. Being explicit about where things go saves you from cleaning up after every generation.
Views: class-based vs function-based
This is the most opinionated choice in any Django project. Pick one approach and enforce it.
If you use Django REST Framework with class-based views:
## Views
Use class-based views with DRF ViewSets for all API endpoints:
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.select_related("profile").all()
serializer_class = UserSerializer
permission_classes = [IsAuthenticated]
filterset_class = UserFilter
def get_queryset(self):
return super().get_queryset().filter(organization=self.request.user.organization)
- Inherit from the appropriate base: ModelViewSet, ReadOnlyModelViewSet, GenericAPIView
- Always define queryset, serializer_class, and permission_classes
- Override get_queryset() for row-level filtering, never filter in the view action
- Use @action decorator for non-CRUD endpoints on a ViewSet
- Do NOT use function-based views with @api_view for standard CRUD operations
If you prefer function-based views (common in smaller projects or with Django Ninja):
## Views
Use function-based views with Django Ninja for all API endpoints:
@router.get("/users", response=list[UserSchema])
def list_users(request, filters: UserFilters = Query(...)):
qs = User.objects.select_related("profile").filter(
organization=request.auth.organization
)
return filters.filter(qs)
- Use Django Ninja routers, not DRF ViewSets
- Type-annotate all parameters and return types
- Use Pydantic schemas (Schema classes), not DRF serializers
- Group related endpoints in a single router file
- Do NOT use class-based views
The key is picking one. Mixed codebases confuse both humans and AI tools.
Models and database queries
Models are where AI tools make their most expensive mistakes. A missing select_related or an N+1 query in a list view can tank performance.
## Models
- All models inherit from src/common/models.py:TimeStampedModel (provides created_at, updated_at)
- Use explicit db_table names in Meta class
- Define __str__ on every model
- Use constraints and validators at the model level, not just in forms/serializers
- Index fields that are filtered or ordered on frequently
- Use TextChoices/IntegerChoices for choice fields, not raw tuples
class Order(TimeStampedModel):
class Status(models.TextChoices):
PENDING = "pending", "Pending"
SHIPPED = "shipped", "Shipped"
DELIVERED = "delivered", "Delivered"
user = models.ForeignKey("users.User", on_delete=models.CASCADE, related_name="orders")
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
class Meta:
db_table = "orders"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["user", "status"]),
]
def __str__(self):
return f"Order {self.pk} - {self.status}"
For queries, enforce N+1 prevention:
## Database queries
- ALWAYS use select_related() for ForeignKey/OneToOne fields accessed in the response
- ALWAYS use prefetch_related() for reverse ForeignKey/ManyToMany fields
- Never call .all() without filtering or pagination in list endpoints
- Use .only() or .defer() for queries that don't need all fields
- Use F() and Q() objects for database-level filtering, not Python-level
- Write new queries in the model's Manager or QuerySet, not in views
- Use bulk_create/bulk_update for batch operations, never loop with .save()
# Bad: N+1 query
users = User.objects.all()
for user in users:
print(user.profile.bio) # hits DB for each user
# Good: single query with join
users = User.objects.select_related("profile").all()
for user in users:
print(user.profile.bio) # no additional queries
This is one of those rules that pays for itself immediately. AI tools regularly generate views that look correct but have hidden N+1 queries. The rule forces them to think about query optimization at generation time, not after you notice the page takes five seconds to load.
Serializers and validation
DRF serializers are where data validation happens. Without rules, AI tools generate flat serializers that skip validation and nested writes.
## Serializers (DRF)
- Use ModelSerializer for standard CRUD, plain Serializer for custom input shapes
- Always define explicit fields list in Meta (never use fields = "__all__")
- Separate read and write serializers when they differ:
- UserReadSerializer (includes nested profile, computed fields)
- UserWriteSerializer (flat IDs, validated input)
- Use validate_<field>() for single-field validation
- Use validate() for cross-field validation
- Read-only computed fields use SerializerMethodField
class UserReadSerializer(serializers.ModelSerializer):
full_name = serializers.SerializerMethodField()
profile = ProfileSerializer(read_only=True)
class Meta:
model = User
fields = ["id", "email", "full_name", "profile", "created_at"]
def get_full_name(self, obj):
return f"{obj.first_name} {obj.last_name}"
The split between read and write serializers prevents a common AI mistake: generating a single serializer that either exposes too many fields on read or requires too many fields on write.
Type hints and mypy
Python's optional typing means AI tools will sometimes skip type annotations entirely. Force the issue.
## Type hints
- All function signatures must have complete type annotations (parameters and return type)
- Use modern syntax: list[str] not List[str], str | None not Optional[str]
- Use TypedDict for dictionary shapes passed between functions
- Django model fields are auto-typed by django-stubs, do not manually annotate them
- Use Protocol for duck-typing interfaces
# Bad
def get_users(filters, limit):
...
# Good
def get_users(filters: UserFilters, limit: int = 50) -> QuerySet[User]:
...
Running mypy in strict mode catches a huge number of issues in AI-generated code. Without type hints, parameters get passed in the wrong order, return types don't match expectations, and you don't find out until runtime.
Testing with pytest
Django's built-in test runner works, but most production projects use pytest with pytest-django. Make sure your AI knows which one.
## Testing
- Use pytest with pytest-django (not unittest, not Django's test runner)
- Test files live in tests/ directories inside each app
- Use pytest fixtures for test data, not setUp methods
- Use factory_boy factories for model instances, not raw Model.objects.create()
- Use `@pytest.mark.django_db` for tests that need database access
- Fixtures go in conftest.py at the app level or project level
# conftest.py
@pytest.fixture
def user(db):
return UserFactory(email="test@example.com")
@pytest.fixture
def api_client(user):
client = APIClient()
client.force_authenticate(user=user)
return client
# test_views.py
class TestUserList:
def test_returns_users_for_authenticated_request(self, api_client, user):
response = api_client.get("/api/users/")
assert response.status_code == 200
assert len(response.data) == 1
assert response.data[0]["email"] == user.email
def test_rejects_unauthenticated_request(self, client):
response = client.get("/api/users/")
assert response.status_code == 401
- Name test classes as TestFeatureName, test methods as test_expected_behavior
- Test both success and failure paths
- Do NOT use self.assertEqual, use plain assert statements
- Do NOT mock the ORM, use the test database
The factory_boy rule is particularly important. Without it, AI tools generate tests with long chains of Model.objects.create() calls with hardcoded values, which makes tests brittle and hard to read.
Migrations and schema changes
## Migrations
- Always run makemigrations after model changes, never edit migrations manually
- Use RunPython for data migrations, not RunSQL (unless performance requires it)
- One logical change per migration file
- Backward-compatible changes first: add new field with default -> backfill -> remove old field
- Never delete or squash migrations without coordinating with the team
A complete rules file for Django
Here's a starter rules file for a typical Django REST Framework project:
# Project: [Your App Name]
## Stack
- Python 3.12+, Django 5.2+ (LTS), Django REST Framework 3.16
- Database: PostgreSQL 17+ via psycopg 3
- Package manager: uv
- Linter/formatter: ruff
- Type checker: mypy (strict)
- Tests: pytest + pytest-django + factory_boy
## Structure
- Config: config/settings/{base,local,production}.py
- Apps: src/apps/[app_name]/
- Shared code: src/common/
## Views
- Class-based views with DRF ViewSets
- Always set queryset, serializer_class, permission_classes
- Override get_queryset() for row-level filtering
- Use @action for non-CRUD operations
## Models
- Inherit from TimeStampedModel
- Use TextChoices for choice fields
- Define __str__ and Meta on every model
- Always add indexes for filtered/ordered fields
## Queries
- select_related for FK/O2O, prefetch_related for reverse/M2M
- No .all() without filtering or pagination
- Queries belong in Managers, not views
## Serializers
- Separate read/write serializers
- Explicit fields in Meta, never "__all__"
- validate_<field>() and validate() for input
## Type hints
- All functions fully typed
- Modern syntax: list[str], str | None
- No Any types
## Testing
- pytest + pytest-django
- factory_boy for test data
- assert statements, not self.assertEqual
- Test success and failure paths
## Do NOT
- Do not use function-based views for CRUD endpoints
- Do not use raw SQL unless performance requires it
- Do not use print() for logging, use the logging module
- Do not use fields = "__all__" in serializers
- Do not skip type annotations
Sharing Python rules across your team
Writing these rules once is the hard part. Keeping them consistent across ten developers and five repositories is where things break down. Someone copies the rules file into a new project but forgets to update the Python version. Another developer adds a local testing convention that doesn't make it back to the shared file.
This is the problem localskills.sh solves. Publish your Django rules as a skill, and every project installs from the same source. When you update a convention, publish a new version and everyone pulls it:
localskills install your-team/django-rules --target cursor claude windsurf
The format differences between Cursor, Claude Code, and Windsurf are handled for you. One skill, every tool. See how rules work across different AI tools for the details, or check out real-world examples to see how other teams structure their rules.
If you want to go deeper on writing effective rules in general, the best practices guide covers the principles that apply to any language or framework.
Ready to standardize your Python and Django conventions across your team and every AI tool? Create your free account and publish your first skill today.
npm install -g @localskills/cli
localskills login
localskills publish