Django REST Framework: Patterns & Best Practices
Section 12 of 17

Custom Exception Handling and Error Responses

One of the marks of a well-crafted API is consistent error responses. Inconsistency in error formats is one of the most frustrating things a frontend developer deals with day-to-day. Imagine having to write this:

// This is what inconsistent errors force on client developers:
if (response.status === 400) {
  if (response.data.detail) {
    showError(response.data.detail)  // Sometimes it's here
  } else if (response.data.non_field_errors) {
    showError(response.data.non_field_errors[0])  // Sometimes here
  } else {
    Object.keys(response.data).forEach(field => {
      showFieldError(field, response.data[field][0])  // Sometimes here
    })
  }
}

That's a mess nobody should have to write. Good API design means every error has the same shape, and client code can handle it uniformly.

DRF's Default Exception Handling

By default, DRF catches APIException subclasses and returns structured responses. But the shapes differ between error types: validation errors return field-keyed objects, permission errors return {"detail": "..."}. This inconsistency forces clients to write defensive, fragile code.

DRF provides a hook called exception_handler that intercepts all exceptions before they're turned into HTTP responses. This is where you normalize everything.

A Consistent Error Response Format

# exceptions.py
from rest_framework.views import exception_handler
from rest_framework import status
from rest_framework.exceptions import APIException
import logging

logger = logging.getLogger(__name__)

def custom_exception_handler(exc, context):
    """
    Returns ALL errors in a consistent format:
    {
        "success": false,
        "error": {
            "code": "validation_error",
            "message": "Validation failed",
            "details": {...}
        }
    }
    """
    # Call DRF's default handler first
    response = exception_handler(exc, context)
    
    if response is not None:
        if response.status_code >= 500:
            logger.error(
                f"Server error {response.status_code}: {exc}",
                exc_info=True,
                extra={
                    'request': context.get('request'),
                    'view': context.get('view').__class__.__name__,
                    'status_code': response.status_code,
                }
            )
        
        error_data = {
            'success': False,
            'error': {
                'code': get_error_code(exc, response.status_code),
                'message': get_error_message(exc, response),
                'details': response.data,
            }
        }
        response.data = error_data
    
    return response

def get_error_code(exc, status_code):
    if hasattr(exc, 'default_code'):
        return exc.default_code
    codes = {
        400: 'bad_request',
        401: 'unauthorized',
        403: 'forbidden',
        404: 'not_found',
        429: 'too_many_requests',
        500: 'internal_server_error',
    }
    return codes.get(status_code, 'error')

def get_error_message(exc, response):
    if isinstance(response.data, dict) and 'detail' in response.data:
        return str(response.data['detail'])
    if isinstance(response.data, dict) and response.status_code == 400:
        return 'The request contained invalid data. Please check the fields below.'
    return 'The request could not be completed.'

Register it in settings:

REST_FRAMEWORK = {
    'EXCEPTION_HANDLER': 'your_app.exceptions.custom_exception_handler',
}

From this moment on, every error your API returns looks like:

{
    "success": false,
    "error": {
        "code": "bad_request",
        "message": "The request contained invalid data. Please check the fields below.",
        "details": {
            "email": ["Enter a valid email address."],
            "title": ["This field is required."]
        }
    }
}

Custom Exception Classes

For domain-specific errors, create custom exception classes:

class PaymentRequired(APIException):
    """User has exceeded quota and needs to upgrade."""
    status_code = status.HTTP_402_PAYMENT_REQUIRED
    default_detail = 'Payment required to access this resource.'
    default_code = 'payment_required'

class AccountSuspended(APIException):
    """Account has been frozen by support."""
    status_code = status.HTTP_403_FORBIDDEN
    default_detail = 'Your account has been suspended. Contact support.'
    default_code = 'account_suspended'

Now raise them in views or serializers where the business rule applies:

class ArticleViewSet(viewsets.ModelViewSet):
    def perform_create(self, serializer):
        user = self.request.user
        
        if user.article_count >= user.subscription_plan.max_articles:
            raise PaymentRequired(
                f"You've reached your plan's article limit. Upgrade to create more."
            )
        
        if user.is_suspended:
            raise AccountSuspended()
        
        serializer.save(author=user)

Reading this code, the intent is crystal clear. Custom exception classes integrate automatically with the logging and response-formatting infrastructure from your custom handler.

With consistent error handling in place, you're ready to write tests that give you real confidence in your API's behavior.