Django Model Patterns
Core Philosophy: Fat Models, Thin Views
Business logic belongs in models and managers, not views. Views orchestrate workflows; models implement domain behavior. This principle creates testable, reusable code that stays maintainable as complexity grows.
Good: Model methods handle business rules, state transitions, validation Bad: Views contain if/else logic for domain rules, calculate derived values
Model Design
Structure Your Models Around Domain Concepts
-
Use TextChoices /IntegerChoices for status fields and enums
-
Add get_absolute_url() for canonical object URLs
-
Include str() for readable representations
-
Set proper ordering in Meta for consistent default sorting
-
Add database indexes for frequently filtered/sorted fields
-
Use abstract base models for shared fields (timestamps, soft deletes, etc.)
Field Selection Guidelines
-
Use blank=True, default="" for optional text fields (avoid null)
-
Use null=True, blank=True for optional foreign keys
-
For unique optional fields, use null=True to avoid collision issues
-
Leverage JSONField for flexible metadata (avoid creating many optional fields)
-
Set appropriate max_length based on actual data needs
Encapsulate Business Logic in Model Methods
-
State transitions: post.publish() , order.cancel()
-
Permission checks: post.is_editable_by(user)
-
Complex calculations: invoice.calculate_total()
-
Use properties for computed read-only values
-
Specify update_fields when saving partial changes
QuerySet Patterns: The Power of Composition
Custom QuerySet classes are your secret weapon. They make queries reusable, chainable, and testable.
Pattern: QuerySet as Manager
Define a QuerySet subclass with domain-specific filter methods Attach it to your model: objects = YourQuerySet.as_manager() Chain methods for composable queries
Benefits
-
Reusable query logic across views, tasks, management commands
-
Chainable methods enable expressive, readable queries
-
Easy to test in isolation
-
Encapsulates query complexity away from views
Common QuerySet Methods
-
Filtering by status/state
-
Date range queries (recent, upcoming, expired)
-
User-scoped queries (owned_by, visible_to)
-
Combined lookups (published_and_recent)
Query Optimization: Avoid N+1 Queries
The Golden Rules
-
select_related(): Use for ForeignKey and OneToOneField (creates SQL JOIN)
-
prefetch_related(): Use for ManyToManyField and reverse ForeignKeys (separate query + Python join)
-
only(): Load specific fields when you don't need the whole object
-
defer(): Exclude heavy fields (TextField, JSONField) you won't use
-
Prefetch() object: Customize prefetch with filters and select_related
Efficient Counting and Existence Checks
-
Use .exists() instead of if queryset: or if len(queryset):
-
Use .count() instead of len(queryset.all())
-
Both perform database-level operations without loading objects
Aggregation and Annotation
-
annotate() : Add computed fields to each object (Count, Sum, Avg, etc.)
-
aggregate() : Compute values across entire queryset
-
Use F() expressions for database-level updates (views=F('views') + 1 )
-
Combine annotate with filter for "objects with at least N related items"
Managers vs QuerySets
Use QuerySets for chainable query logic. Use Managers for model-level operations that don't return querysets.
Manager: Think "factory methods" - User.objects.create_user()
QuerySet: Think "filters and transformations" - Post.objects.published().recent()
Most of the time, you want a custom QuerySet, not a custom Manager.
Signals: Use Sparingly
Signals create implicit coupling and make code harder to follow. Prefer explicit method calls.
When Signals Make Sense
-
Audit logging (track all changes to a model)
-
Cache invalidation (clear cache when model changes)
-
Decoupling apps (third-party app needs to react to your models)
When to Avoid Signals
-
Business logic that should be in model methods
-
Logic tightly coupled to the calling code (just call the function directly)
-
Complex workflows (use explicit service layer instead)
Rule of thumb: If you control both the trigger and the reaction, don't use a signal.
Migrations
Workflow
-
Run makemigrations after model changes
-
Review generated migration files before applying
-
Run migrate to apply migrations
-
Migrations should be reversible when possible
Data Migrations
Create with makemigrations --empty app_name . Use apps.get_model() to access models (not direct imports). Write both forward and reverse operations.
Use data migrations for: Populating new fields, transforming data, migrating between fields.
Anti-Patterns to Avoid
Query Anti-Patterns
-
Iterating over objects and accessing relations without select_related() /prefetch_related()
-
Using if queryset: instead of .exists()
-
Using len() to count instead of .count()
-
Loading entire objects when you only need specific fields
Design Anti-Patterns
-
Business logic in views instead of models
-
Views performing calculations that belong in model methods
-
Overusing signals for synchronous operations
-
Creating new models when JSONField would suffice
-
Forgetting to add indexes for filtered/sorted fields
Integration
Works with:
-
pytest-django-patterns: Factory-based model testing
-
celery-patterns: Async operations on models (pass IDs, not instances)
-
django-forms: ModelForm validation and saving