Python + Django + DRF
Project context
This is a Django 5+ project with Django REST Framework for the API layer. We follow Django conventions ruthlessly — convention over configuration is a feature, not a constraint. Postgres for the DB. Production target.
Stack
- Python 3.12+
- Django 5+
- Django REST Framework 3.15+
- Postgres 15+ (psycopg3 driver)
uvfor package management- pytest +
pytest-djangofor tests - Ruff for lint + format
Folder structure
Follow Django app conventions. One app per bounded domain (e.g., users, billing, posts).
project/
settings/
base.py
dev.py
prod.py
urls.py
wsgi.py
asgi.py
apps/
users/
models.py
serializers.py
views.py
urls.py
admin.py
tests/
migrations/
posts/
...
Settings split: base.py for shared, dev.py and prod.py import from base and override.
Models
- One concern per model
- Always set
Meta.orderingif the model is queried unordered - Use
db_index=Trueon fields used infilter()ororder_by() - Use
null=Trueonly when the absence is meaningful — for strings, preferdefault="" - Custom
Managerfor non-trivial query sets;QuerySet.as_manager()for chainable methods
class PostQuerySet(models.QuerySet):
def published(self):
return self.filter(published_at__isnull=False)
class Post(models.Model):
title = models.CharField(max_length=200)
body = models.TextField()
published_at = models.DateTimeField(null=True, blank=True)
objects = PostQuerySet.as_manager()
class Meta:
ordering = ["-published_at"]
indexes = [models.Index(fields=["published_at"])]
Migrations
python manage.py makemigrations— generate- Review the SQL:
python manage.py sqlmigrate <app> <migration> - Never edit a committed migration; create a new one
- For data migrations, use
RunPythonwith explicitforwardsandreverse migrate --planbefore deploying to verify what will run
DRF serializers
- Separate read and write serializers when they diverge
- Use
serializers.ModelSerializerfor trivial CRUD; switch toSerializerwhen logic gets complex - Validate at the serializer (
validate_<field>,validate); never re-validate in the view
class PostSerializer(serializers.ModelSerializer):
class Meta:
model = Post
fields = ["id", "title", "body", "published_at"]
read_only_fields = ["id", "published_at"]
def validate_title(self, value):
if len(value) < 3:
raise serializers.ValidationError("Title too short.")
return value
DRF views
- ViewSets for resource APIs (
ModelViewSetwhen you want all 5 actions) - Generic views (
ListAPIView,RetrieveAPIView) when you only want some actions - APIView for non-CRUD endpoints
- Always declare
permission_classesexplicitly — never rely on defaults - Always declare
pagination_classfor list endpoints
URLs
- Use DRF routers for ViewSets
- Use
path()for non-router endpoints - Namespace every app's URLs (
app_name = "posts")
Authentication
- DRF's
SessionAuthenticationfor browser-based usage TokenAuthenticationor JWT (djangorestframework-simplejwt) for API clients- Custom permission classes go in
<app>/permissions.py
Patterns to follow
- Fat models, thin views, skinny controllers. Business logic on the model or its manager.
select_related/prefetch_relatedin any list view that touches relations. The Django Debug Toolbar will flag N+1.@transaction.atomicaround any multi-step writeget_object_or_404instead of try/except for "not found" cases- Signals sparingly — they make the code path implicit. Prefer explicit calls.
Patterns to avoid
null=TrueonCharField/TextField— usedefault=""auto_now_add+auto_nowwithouteditable=False— they leak into admin / forms- Editing a committed migration — always create a new one
- Catching
Exceptionbroadly — be specific - Raw SQL unless absolutely necessary — and then with
params, never string formatting - Using DRF's
BrowsableAPIRendererin production — it leaks views' Python objects
Testing
pytest-djangowith@pytest.mark.django_db- Use
factory_boyfor model factories - Use
APIClientfor DRF endpoint tests - One test file per module; one test per behavior
@pytest.mark.django_db
def test_create_post(client, user):
client.force_authenticate(user)
res = client.post("/api/posts/", {"title": "Hi", "body": "..."})
assert res.status_code == 201
Tooling
python manage.py runserver— dev serverpython manage.py makemigrations && migrate— schema changespython manage.py shell_plus(django-extensions) — shell with auto-importspytest— testsruff check && ruff format— lint + format
AI behavioral rules
- Don't invent Django APIs — many model methods sound right but don't exist (
Model.save_async,objects.find). Verify against current Django docs. - Always run
makemigrationsafter editing a model; never skip - Never edit a committed migration
- Use
select_related/prefetch_relatedwhenever a list view touches a foreign key - Validate inputs in the serializer, not the view
- Always set
permission_classesandpagination_classexplicitly - Run
pytest,ruff check, andpython manage.py checkbefore declaring done