drf-commons is a utility library for Django REST Framework that eliminates architectural repetition, enforces API consistency, and provides composable abstractions for building scalable, maintainable REST APIs.
- Why This Exists
- What drf-commons Solves
- Compatibility
- Feature Overview
- Architecture Philosophy
- Installation
- Quickstart
- Before vs After
- Core Components
- Design Principles
- Production Usage
- Extensibility
- Performance Considerations
- Use Cases
- Changelog
- Contributing
- License
Django REST Framework is a powerful toolkit that excels at the fundamentals of REST API construction. However, production API development at scale consistently exposes structural limitations that DRF does not address out of the box:
Inconsistent Response Envelopes
DRF returns raw serialized data with no standard envelope. In a production system with multiple teams, endpoints return data in structurally inconsistent forms — some wrap in {"data": ...}, others return arrays, others return flat objects. Clients cannot rely on a predictable contract.
Repetitive ViewSet Patterns
Every resource requires the same boilerplate: permission classes, serializer resolution, queryset filtering, pagination integration, error handling. In a system with 50+ resources, this is thousands of lines of structural duplication.
No Audit Trail
DRF has no built-in mechanism for tracking who created or last modified a record. The common workarounds (request context passing, serializer overrides) are fragile and scatter responsibility across layers.
Primitive Bulk Operation Support
Bulk create, update, and delete are not first-class citizens in DRF. Rolling your own bulk operations means reimplementing transaction safety, validation, and audit population for each resource — often inconsistently.
Unsafe Thread-Local User Storage
The standard pattern of storing the current user in thread-local storage (threading.local()) breaks in async environments. With ASGI becoming the deployment standard, thread-local user storage introduces subtle bugs that are difficult to diagnose.
Rigid Serializer Field Behavior
DRF's relational fields are write-as-ID, read-as-ID. Representing the same relation differently depending on context (ID write / nested read, or nested write / ID read) requires custom field classes every time — a disproportionate amount of code for a common requirement.
No Built-In Import/Export
Data import from CSV/XLSX and export to multiple formats are universally required in production systems but completely absent from DRF's scope.
Scattered Debug Tooling
Query counting, slow request detection, SQL profiling, and structured logging require third-party packages assembled without coherence.
drf-commons is not a framework on top of DRF — it is a structural layer composed atop DRF internals. It follows the principle of progressive enhancement: you adopt what you need, it composes with what you have, and it never breaks what DRF already provides.
| Problem | drf-commons Solution |
|---|---|
| Inconsistent responses | success_response() / error_response() with ISO8601 timestamps |
| Repetitive viewset boilerplate | Pre-composed ViewSet classes (BaseViewSet, BulkViewSet, etc.) |
| No audit trail | UserActionMixin + CurrentUserMiddleware + ContextVar-based user resolution |
| Unsafe thread-local user | ContextVar-based get_current_user() with async support |
| Primitive bulk operations | BulkCreateModelMixin, BulkUpdateModelMixin, BulkDeleteModelMixin with transaction safety |
| Rigid serializer fields | 20+ configurable field types (IdToDataField, FlexibleField, etc.) |
| No import/export | FileImportService, ExportService with CSV/XLSX/PDF support |
| Scattered debug tooling | Unified StructuredLogger, SQLDebugMiddleware, performance decorators |
| Soft delete complexity | SoftDeleteMixin with soft_delete(), restore() |
| No optimistic locking | VersionMixin with conflict detection |
| drf-commons | Python | Django | DRF |
|---|---|---|---|
| 1.x | 3.8 – 3.12 | 3.2 – 5.x | 3.12+ |
BaseModelMixin— UUID PK, timestamps, soft delete, audit trail, JSON serializationTimeStampMixin—created_at,updated_atauto-populationUserActionMixin—created_by,updated_byauto-population from request contextSoftDeleteMixin— Non-destructive deletion with restore capabilityVersionMixin— Optimistic locking withVersionConflictErrorSlugMixin— Deterministic slug generation with collision avoidanceMetaMixin—metadataJSONField,tags,noteswith helper methodsIdentityMixin— Person identity fields with computed propertiesAddressMixin— Structured address fields with coordinate supportCurrentUserField— ForeignKey auto-populated from request context
BaseViewSet— Full CRUD with file exportBulkViewSet— CRUD + bulk create/update/deleteReadOnlyViewSet,CreateListViewSet— Restricted resource access patternsImportableViewSet,BulkImportableViewSet— File import-capable viewsets- Configurable
return_data_on_create,return_data_on_update - Optional
append_indexesfor sequentially numbered list results
BaseModelSerializer— Handles complex relational write patterns atomicallyBulkUpdateListSerializer— Efficient bulk updates viabulk_update()- 20+ configurable field types covering all relation access patterns
FlexibleField— Auto-detects input format, returns configured output
BulkCreateModelMixin— Atomic bulk creation with validationBulkUpdateModelMixin— Efficientbulk_update()or individual save modesBulkDeleteModelMixin— Bulk delete + bulk soft delete with missing ID reporting
FileImportService— Multi-model CSV/XLSX imports with transformation hooksExportService— CSV, XLSX, PDF exportFileImportMixin,FileExportMixin— ViewSet-level integration- Management command:
generate_import_template
CurrentUserMiddleware— Async/sync middleware for ContextVar user injectionStructuredLogger— Category-based structured loggingSQLDebugMiddleware,ProfilerMiddleware— Development debug toolingStandardPageNumberPagination,LimitOffsetPaginationWithFormatComputedOrderingFilter— Ordering on annotated/computed fieldscache_debug,api_request_logger,log_db_query,api_performance_monitordecoratorsMiddlewareChecker— Runtime middleware validation
drf-commons is built on three foundational principles:
1. Composition Over Inheritance
All components are designed as mixins. BaseViewSet is CreateModelMixin + ListModelMixin + RetrieveModelMixin + UpdateModelMixin + DestroyModelMixin + FileExportMixin. You can compose exactly the combination you need rather than inheriting a monolith.
2. Explicit Over Implicit
The library never silently modifies behavior. Every enhancement (audit tracking, response formatting, bulk operations) is a conscious integration choice. Configuration is explicit and overridable at every layer.
3. Framework-Aligned, Not Framework-Replacing
drf-commons works with DRF's internal dispatch, serializer resolution, and authentication layers. It does not subvert DRF internals — it extends them using DRF's own documented extension points.
pip install drf-commons# File export support (CSV, XLSX, PDF)
pip install drf-commons[export]
# File import support (CSV, XLS, XLSX via pandas)
pip install drf-commons[import]
# Debug and profiling utilities
pip install drf-commons[debug]
# All optional features
pip install drf-commons[export,import,debug]# settings.py
INSTALLED_APPS = [
...
'drf_commons',
...
]
MIDDLEWARE = [
...
'drf_commons.middlewares.CurrentUserMiddleware',
...
]
# Optional: override drf-commons defaults
COMMON = {
'BULK_OPERATION_BATCH_SIZE': 1000,
'IMPORT_BATCH_SIZE': 250,
'DEBUG_SLOW_REQUEST_THRESHOLD': 1.0,
'DEBUG_HIGH_QUERY_COUNT_THRESHOLD': 10,
}from django.db import models
from drf_commons.models import BaseModelMixin
class Article(BaseModelMixin):
title = models.CharField(max_length=255)
content = models.TextField()
published = models.BooleanField(default=False)
class Meta:
ordering = ['-created_at']BaseModelMixin provides: UUID primary key, created_at, updated_at, created_by, updated_by, is_active, deleted_at, and get_json().
from drf_commons.serializers import BaseModelSerializer
from drf_commons.serializers.fields import IdToDataField
class ArticleSerializer(BaseModelSerializer):
author = IdToDataField(queryset=User.objects.all(), serializer=UserSerializer)
class Meta:
model = Article
fields = ['id', 'title', 'content', 'published', 'author', 'created_at']from drf_commons.views import BaseViewSet
class ArticleViewSet(BaseViewSet):
queryset = Article.objects.filter(is_active=True)
serializer_class = ArticleSerializer
# Optional: configure bulk operations
bulk_batch_size = 500
# Optional: configure export
export_field_config = {
'title': 'Title',
'content': 'Content',
'published': 'Published',
}from rest_framework.routers import DefaultRouter
from .views import ArticleViewSet
router = DefaultRouter()
router.register('articles', ArticleViewSet, basename='article')
urlpatterns = router.urlsThis gives you: GET /articles/, POST /articles/, GET /articles/{id}/, PUT/PATCH /articles/{id}/, DELETE /articles/{id}/, POST /articles/export/.
Before (vanilla DRF)
class ArticleViewSet(ViewSet):
def list(self, request):
articles = Article.objects.all()
serializer = ArticleSerializer(articles, many=True)
return Response(serializer.data) # raw list, no envelope
def create(self, request):
serializer = ArticleSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400) # inconsistent shapeAfter (drf-commons)
class ArticleViewSet(BaseViewSet):
queryset = Article.objects.filter(is_active=True)
serializer_class = ArticleSerializer
# All responses automatically formatted:
# {"success": true, "timestamp": "...", "data": [...], "message": "..."}Before (vanilla DRF)
class ArticleSerializer(ModelSerializer):
def create(self, validated_data):
# must manually inject request context
request = self.context.get('request')
validated_data['created_by'] = request.user
validated_data['updated_by'] = request.user
return super().create(validated_data)
def update(self, instance, validated_data):
request = self.context.get('request')
validated_data['updated_by'] = request.user
return super().update(instance, validated_data)After (drf-commons)
class Article(BaseModelMixin):
title = models.CharField(max_length=255)
# created_by, updated_by populated automatically via ContextVar
# No serializer override requiredBefore (vanilla DRF)
@action(detail=False, methods=['post'])
def bulk_create(self, request):
serializer = ArticleSerializer(data=request.data, many=True)
if serializer.is_valid():
try:
with transaction.atomic():
instances = [Article(**item) for item in serializer.validated_data]
Article.objects.bulk_create(instances)
except Exception as e:
return Response({'error': str(e)}, status=500)
return Response({'created': len(instances)}, status=201)
return Response(serializer.errors, status=400)After (drf-commons)
class ArticleViewSet(BulkViewSet):
queryset = Article.objects.filter(is_active=True)
serializer_class = ArticleSerializer
# POST /articles/bulk-create/ — handled automatically
# PUT /articles/bulk-update/ — handled automatically
# DELETE /articles/bulk-delete/ — handled automaticallyBefore (vanilla DRF)
class ArticleSerializer(ModelSerializer):
# Can't read nested author data while writing by ID without custom field
author_id = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(), write_only=True
)
author = UserSerializer(read_only=True)
def to_representation(self, instance):
# Override needed to conditionally show nested data
...After (drf-commons)
class ArticleSerializer(BaseModelSerializer):
author = IdToDataField(queryset=User.objects.all(), serializer=UserSerializer)
# Write: accept user ID
# Read: return full nested UserSerializer outputThe canonical base model providing UUID primary key, timestamping, user action tracking, soft deletion, and JSON serialization.
from drf_commons.models import BaseModelMixin
class Product(BaseModelMixin):
name = models.CharField(max_length=255)
sku = models.CharField(max_length=64, unique=True)Provides:
id— UUID primary keycreated_at,updated_at— ISO8601 timestamps (auto-populated)created_by,updated_by— ForeignKey toAUTH_USER_MODEL(auto-populated from request context)is_active— Soft delete flagdeleted_at— Soft delete timestampget_json(**kwargs)— Flexible JSON serialization
Implements optimistic locking for high-concurrency write scenarios.
from drf_commons.models.content import VersionMixin
class Document(BaseModelMixin, VersionMixin):
body = models.TextField()On concurrent modification:
# Raises drf_commons.models.content.VersionConflictError
doc.increment_version()
doc.save()Auto-generates URL-safe slugs with deterministic collision avoidance.
class Category(BaseModelMixin, SlugMixin):
name = models.CharField(max_length=255)
def get_slug_source(self):
return self.name
# Generates: "product-category", "product-category-1", etc.| Class | Actions |
|---|---|
BaseViewSet |
CRUD + export |
BulkViewSet |
CRUD + bulk create/update/delete + export |
ReadOnlyViewSet |
List + retrieve + export |
CreateListViewSet |
Create + list + export |
BulkCreateViewSet |
Bulk create only |
BulkUpdateViewSet |
Bulk update only |
BulkDeleteViewSet |
Bulk delete only |
BulkOnlyViewSet |
All bulk operations |
ImportableViewSet |
CRUD + file import + export |
BulkImportableViewSet |
CRUD + bulk ops + file import + export |
from drf_commons.views import BulkViewSet
class OrderViewSet(BulkViewSet):
queryset = Order.objects.select_related('customer', 'items')
serializer_class = OrderSerializer
permission_classes = [IsAuthenticated]
filterset_class = OrderFilterSetExtends DRF's ModelSerializer with:
- Atomic transaction wrapping for all writes
root_firstrelation write ordering (write parent, then set FK on children)dependency_firstrelation write ordering (resolve dependencies before root)
from drf_commons.serializers import BaseModelSerializer
class InvoiceSerializer(BaseModelSerializer):
line_items = LineItemSerializer(many=True)
class Meta:
model = Invoice
fields = ['id', 'customer', 'line_items', 'total']| Field | Write Input | Read Output |
|---|---|---|
IdToDataField |
ID | Nested serializer data |
IdToStrField |
ID | String representation |
DataToIdField |
Nested data | ID |
DataToStrField |
Nested data | String |
DataToDataField |
Nested data | Nested data |
StrToDataField |
String lookup | Nested data |
IdOnlyField |
ID | ID |
StrOnlyField |
String | String |
FlexibleField |
ID or string | Nested data |
ReadOnlyDataField |
N/A | Nested data |
Many-to-many variants: ManyIdToDataField, ManyDataToIdField, ManyStrToDataField, ManyIdOnlyField, ManyFlexibleField.
All viewset responses are automatically wrapped in a standardized envelope:
# Success
{
"success": true,
"timestamp": "2026-01-15T10:30:00.000000Z",
"message": "Operation completed successfully.",
"data": { ... }
}
# Error
{
"success": false,
"timestamp": "2026-01-15T10:30:00.000000Z",
"message": "Validation failed.",
"errors": { "field": ["error message"] },
"data": null
}Use directly in custom views:
from drf_commons.response import success_response, error_response
def my_view(request):
return success_response(data={'key': 'value'}, message='Done.')
return error_response(message='Not found.', status_code=404)Supports two modes controlled by use_save_on_bulk_update:
Default mode (use_save_on_bulk_update = False) — Uses QuerySet.bulk_update() for maximum database efficiency. Audit fields (updated_at, updated_by) are automatically populated when not present in the payload.
Save mode (use_save_on_bulk_update = True) — Calls instance.save() on each object. Use when save() signal logic is required.
class ProductViewSet(BulkViewSet):
queryset = Product.objects.all()
serializer_class = ProductSerializer
use_save_on_bulk_update = False # default: bulk_update()
bulk_batch_size = 500Returns a detailed deletion report:
{
"success": true,
"data": {
"requested_count": 10,
"count": 8,
"missing_ids": ["uuid-1", "uuid-2"]
}
}class ReportViewSet(BaseViewSet):
queryset = Report.objects.all()
serializer_class = ReportSerializer
export_field_config = {
'title': 'Report Title',
'created_at': 'Date Created',
'status': 'Status',
}
# POST /reports/export/
# Body: {"file_type": "xlsx", "includes": ["title", "status"]}class EmployeeViewSet(ImportableViewSet):
queryset = Employee.objects.all()
serializer_class = EmployeeSerializer
import_file_config = {
'file_format': 'xlsx',
'order': ['department', 'employee'],
'models': {
'department': {
'model': Department,
'fields': ['name', 'code'],
'unique_fields': ['code'],
},
'employee': {
'model': Employee,
'fields': ['first_name', 'last_name', 'email', 'department_code'],
'unique_fields': ['email'],
'foreign_keys': {
'department': {'model': Department, 'lookup_field': 'code', 'source_field': 'department_code'}
}
}
}
}
# POST /employees/import-from-file/
# Form: file=<file>, append_data=trueGenerate an import template:
python manage.py generate_import_template EmployeeViewSetInjects the authenticated request user into Python's ContextVar for the duration of each request, enabling automatic created_by/updated_by population at the model layer without serializer context threading.
# settings.py
MIDDLEWARE = [
...
'drf_commons.middlewares.CurrentUserMiddleware',
...
]Supports both WSGI (sync) and ASGI (async) deployments. Uses contextvars.ContextVar — safe across coroutines in async contexts.
from drf_commons.current_user import get_current_user, get_current_authenticated_user
user = get_current_user() # Returns User or None
user = get_current_authenticated_user() # Returns User or raises if anonymousfrom drf_commons.decorators import (
api_request_logger,
log_function_call,
log_exceptions,
log_db_query,
api_performance_monitor,
cache_debug,
)
@api_request_logger(log_body=True, log_headers=False)
@api_performance_monitor()
def my_api_view(request):
...
@log_db_query(query_type='read')
def fetch_heavy_data():
...
@log_function_call(logger_name='billing', log_args=True, log_result=True)
@log_exceptions(reraise=True)
def process_payment(order_id, amount):
...from drf_commons.pagination import StandardPageNumberPagination, LimitOffsetPaginationWithFormat
from drf_commons.filters import ComputedOrderingFilter
class ArticleViewSet(BaseViewSet):
pagination_class = StandardPageNumberPagination # page_size=20, max=100
filter_backends = [ComputedOrderingFilter]
ordering_fields = ['title', 'created_at']
computed_ordering_fields = {
'comment_count': Count('comments'), # Annotates and orders by aggregate
}from drf_commons.debug import StructuredLogger
logger = StructuredLogger('myapp')
logger.log_user_action(user=request.user, action='UPDATE', resource='Article', details={'id': article.id})
logger.log_api_request(request=request, response=response, duration=0.145)
logger.log_performance(operation='bulk_import', duration=2.3, details={'rows': 5000})Enable debug middleware for development:
# settings.py (development only)
MIDDLEWARE += [
'drf_commons.middlewares.SQLDebugMiddleware',
'drf_commons.middlewares.ProfilerMiddleware',
]Single Responsibility — Each mixin class has one clearly defined responsibility. Composition at the viewset or model level is explicit.
No Magic — drf-commons does not auto-discover, monkey-patch, or alter DRF's global behavior. All integration is opt-in.
Async-First — Context management uses ContextVar, not threading.local(). Middleware supports both WSGI and ASGI handlers.
Database Efficiency — Bulk operations default to QuerySet.bulk_update() and QuerySet.bulk_create(), avoiding O(n) query patterns.
Fail Loudly — VersionConflictError, MiddlewareChecker.require(), and configuration validators surface problems at startup or on first use rather than producing silent failures.
drf-commons validates its own middleware requirements at application startup. If UserActionMixin or CurrentUserField is used without CurrentUserMiddleware, the application raises ImproperlyConfigured at startup rather than failing silently at runtime.
# settings.py
COMMON = {
# Bulk operation chunk sizing
'BULK_OPERATION_BATCH_SIZE': 2000,
# Import processing chunk sizing
'IMPORT_BATCH_SIZE': 500,
# Query performance thresholds for alerting
'DEBUG_SLOW_REQUEST_THRESHOLD': 0.5, # seconds
'DEBUG_HIGH_QUERY_COUNT_THRESHOLD': 20, # query count
'DEBUG_SLOW_QUERY_THRESHOLD': 0.05, # seconds per query
}class ArticleViewSet(BaseViewSet):
queryset = Article.objects.filter(is_active=True) # exclude soft-deleted
def perform_destroy(self, instance):
instance.soft_delete() # non-destructive deletionEvery component in drf-commons is a building block, not a ceiling.
from drf_commons.views.mixins import (
ListModelMixin,
RetrieveModelMixin,
BulkCreateModelMixin,
FileExportMixin,
)
from rest_framework.viewsets import GenericViewSet
class CustomViewSet(
ListModelMixin,
RetrieveModelMixin,
BulkCreateModelMixin,
FileExportMixin,
GenericViewSet
):
"""Read-only viewset with bulk creation and export, no standard create."""
passfrom drf_commons.response import success_response
def custom_response(data, message='', **meta):
response = success_response(data=data, message=message)
response.data['_meta'] = meta
return responseclass AuditedSerializer(BaseModelSerializer):
def create(self, validated_data):
instance = super().create(validated_data)
AuditLog.objects.create(
action='CREATE',
model=instance.__class__.__name__,
object_id=instance.pk,
user=get_current_user(),
)
return instance- Bulk operations bypass individual
save()calls usingbulk_update()andbulk_create(). For 1000-record updates, this reduces database round-trips from 1000 to 1. - ContextVar user resolution eliminates per-request serializer context threading overhead.
select_related/prefetch_related—drf-commonsdoes not force queryset evaluation; queryset optimization remains your responsibility.- Chunk-based import processes large files in configurable batches, bounding peak memory usage.
- Lazy-loaded exporters — Export and import dependencies (
openpyxl,pandas,weasyprint) are imported only when the relevant service is invoked.
Multi-tenant SaaS APIs — UserActionMixin + CurrentUserMiddleware provide consistent audit trails across all tenant operations without per-view instrumentation.
High-volume data pipelines — BulkViewSet + configurable batch sizes handle thousands of records per API call efficiently.
Enterprise data management — FileImportService supports multi-model imports with dependency ordering, foreign key resolution, and progress callbacks.
Microservice backends — Standardized response envelopes enable consistent API contracts across multiple service deployments.
Internal tooling APIs — Pre-composed viewsets reduce the time to a working, production-quality endpoint from hours to minutes.
- Initial public release
- Full model mixin suite:
BaseModelMixin,VersionMixin,SlugMixin,SoftDeleteMixin - Pre-composed ViewSet classes including
BulkViewSetandImportableViewSet - 20+ configurable serializer field types
CurrentUserMiddlewarewith ASGI/WSGI support viaContextVar- CSV, XLSX, PDF export via
ExportService - Multi-model CSV/XLSX import via
FileImportService StructuredLogger,SQLDebugMiddleware,ProfilerMiddleware
Contributions are welcome. Please read the development documentation before submitting a pull request.
git clone https://github.com/htvictoire/drf-commons
cd drf-commons
pip install -e ".[export,import,debug]"
pip install -r docs/requirements.txt
make install-dev
make quality # format + lint + type-check
make testMIT License. See LICENSE for full text.
Maintained by Victoire Habamungu · Built for production Django REST Framework deployments.