Control API Field Visibility in Django REST Framework with read_only_fields and write_only_fields
Use read_only_fields and write_only_fields in a DRF ModelSerializer Meta class to block client input on IDs and timestamps and keep passwords out of responses.
23 Jan 2026, 12:47 UTC

The problem: clients can overwrite server-owned fields and read secrets
A ModelSerializer automatically generates fields from your Django model. Without visibility controls, a client can send id, created_at, or other computed values in a POST or PUT request, and the serializer may accept them into validated_data. The same serializer can also return sensitive fields such as password or api_token in GET responses.
The useful takeaway: declare server-owned fields in Meta.read_only_fields and secret input fields in Meta.write_only_fields. DRF then drops read-only fields during deserialization and omits write-only fields from serialized output, with no custom field declarations required.
How the mechanism works
Deserialization is the process of turning incoming request JSON into Python data. When you call serializer.is_valid(), DRF builds validated_data. Fields listed in read_only_fields are removed from that dictionary, so the model save never sees them.
Serialization is the process of turning a model instance or queryset into response JSON. Fields listed in write_only_fields are accepted in requests and remain in validated_data, but they are excluded from serializer.data and therefore from the HTTP response.
These options are declared alongside model and fields in the serializer Meta class. They accept a list or tuple of field names. Behavior is stable across DRF 3.x and early DRF 4.x; check release notes before upgrading to a new major version.
Worked configuration: account registration serializer
Suppose you have an account model:
from django.db import models
class Account(models.Model):
email = models.EmailField(unique=True)
password = models.CharField(max_length=128)
created_at = models.DateTimeField(auto_now_add=True)
The serializer marks id and created_at as read-only and password as write-only:
from rest_framework import serializers
from .models import Account
class AccountSerializer(serializers.ModelSerializer):
class Meta:
model = Account
fields = ["id", "email", "password", "created_at"]
read_only_fields = ["id", "created_at"]
write_only_fields = ["password"]
A viewset that consumes the write-only password correctly:
from rest_framework import viewsets
from .models import Account
from .serializers import AccountSerializer
class AccountViewSet(viewsets.ModelViewSet):
queryset = Account.objects.all()
serializer_class = AccountSerializer
def perform_create(self, serializer):
password = serializer.validated_data.pop("password")
account = serializer.save()
account.set_password(password)
account.save()
Directionality for each field:
| Field | In POST/PUT body | In validated_data | In GET response |
|---|---|---|---|
| id | Ignored | Absent | Included |
| created_at | Ignored | Absent | Included |
| Accepted | Present | Included | |
| password | Accepted | Present | Omitted |
Verify behavior in the Django shell
Run this from the project directory, as the same system user that owns the application files:
cd /path/to/project
python manage.py shell
Inside the shell, instantiate the serializer and inspect all three data shapes. Replace accounts with your actual app label:
from django.db import transaction
from accounts.models import Account
from accounts.serializers import AccountSerializer
with transaction.atomic():
serializer = AccountSerializer(data={
"email": "[contact removed]",
"password": "secret123",
"id": 999,
"created_at": "2026-01-01T00:00:00Z",
})
print("is_valid:", serializer.is_valid())
print("validated_data:", serializer.validated_data)
if serializer.is_valid():
serializer.save()
instance = Account.objects.get(email="[contact removed]")
output = AccountSerializer(instance)
print("response data:", output.data)
transaction.set_rollback(True)
Expected checks: is_valid is true even though the payload included id and created_at; those keys are absent from validated_data. The saved instance exists, but output.data contains id and created_at while omitting password. The transaction.atomic() block with set_rollback(True) prevents the test row from persisting.
Risk: the shell can mutate the database. Do not run unverified shell code against a production database without a backup or a rollback strategy.
Limits and common mistakes
- read_only_fields does not remove fields from the browsable API form or from serialized output. Combine it with permission classes if you need to restrict actual access.
- write_only_fields does not hash or encrypt values. A password remains plain text in validated_data; the view must explicitly consume it, typically with set_password() for Django user models.
- These options operate at the serializer layer only. They do not enforce database-level immutability and do not make nested related serializers read-only.
- Declaring a field as write-only does not remove it from validated_data. If your view does not pop or otherwise consume it, ModelSerializer.save() may write it to the model automatically.
Practical verification with a test
Write a Django test that submits a read-only field in POST data and asserts the saved model instance is unchanged, then GETs the instance and asserts a write-only field is absent from response JSON. Open rest_framework/serializers.py in your installed DRF package to confirm how your version handles Meta.read_only_fields and Meta.write_only_fields.
This configuration changes no database schema and requires no migration. If you need to revert it, remove the read_only_fields and write_only_fields declarations from the serializer Meta class and redeploy.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.