Field validators, validate_<field>, and validate(): the three levels, the exact sequence they fire in, and the PATCH trap nobody warns you about.
If you build APIs with Django REST Framework, serializer validation is the gate every piece of incoming data passes through. Most developers wire it up by copying examples without really knowing when each piece runs. So they put a check in the wrong place, it silently never fires, bad data gets through, and they can't figure out why. Or they write cross-field logic in validate() and it mysteriously breaks on PATCH requests.
The truth is that DRF validation is a small, orderly pipeline. Data comes in, passes through three distinct levels of checks in a specific sequence, and comes out the other side as clean validated_data you can trust.
Once you know the three levels and the order they fire in, you'll always know exactly where to put a given check, and why the one you put in the wrong place isn't running. Let's walk the whole pipeline. Everything here is from the official DRF docs, current for 2026.
The Entry Point: is_valid() and the Two Outcomes
Everything starts when you call .is_valid() on a serializer you've fed incoming data:
serializer = BookSerializer(data=request.data)
if serializer.is_valid():
serializer.save()
else:
return Response(serializer.errors, status=400)
is_valid() runs the entire validation pipeline and returns True or False. The two outcomes are strict and worth internalizing:
-
If it returns
True, you getserializer.validated_data: a dictionary of clean, checked, type-converted values, safe to build or update an object with. This is the only timevalidated_dataexists. You cannot access it beforeis_valid()has run and passed. -
If it returns
False, you getserializer.errors: a dictionary mapping each field name to a list of error messages (plus a specialnon_field_errorskey, which we'll get to).
There's also a shortcut you'll see everywhere: is_valid(raise_exception=True). Instead of returning False, it raises a ValidationError, which DRF's exception handler automatically turns into a clean HTTP 400 response with the errors as the body. That's why a lot of DRF code just calls serializer.is_valid(raise_exception=True) and never checks the return. The 400 is handled for you.
So is_valid() is the door. Behind it, the data flows through three levels, and the order is the thing to get right.
The Three Levels, in the Exact Order They Fire
Here is the sequence DRF runs, for each request, top to bottom. Memorize this order. It's the whole article:
1. Field deserialization + field validators
↓
2. validate_<fieldname>() methods
↓
3. The object-level validate() method
Let's take them one at a time.
Level 1: Field-level built-in validation and validators
First, DRF takes each incoming field and runs its built-in checks. Is this a valid integer? Is this CharField within max_length? Is this required field present? These come from how you declared the field. You can also attach explicit validators to a field, using reusable validator functions or classes:
from rest_framework import serializers
class BookSerializer(serializers.Serializer):
title = serializers.CharField(max_length=200)
isbn = serializers.CharField(validators=[validate_isbn_format]) # runs here, level 1
This level is about each field on its own: the right type, the right shape, present if required. Only fields that pass this level move on.
Level 2: validate_<fieldname>() for custom single-field checks
Next, for any field where you've defined a method named validate_<fieldname>, DRF calls it with that field's value. This is where you put custom rules for one field:
def validate_title(self, value):
if "forbidden" in value.lower():
raise serializers.ValidationError("Title contains a banned word.")
return value # ← you MUST return the (possibly modified) value
Two things people forget here, both important:
-
You must
return value. The method's return becomes the field's entry invalidated_data. Forget thereturnand the field silently becomesNone, a classic, maddening bug. -
It only runs if the field passed Level 1. If
titlefailed its built-inCharFieldcheck (or a required field was missing),validate_titleis never called. So a check you put invalidate_titlewon't fire for absent or malformed input. That's exactly right, but it surprises people who expected it to always run.
Level 3: validate(self, data) for cross-field checks
Finally, after all fields have individually passed, DRF calls the object-level validate() method once, with a dictionary of all the field values. This is where validation that needs to see multiple fields together lives:
def validate(self, data):
if data["start_date"] > data["end_date"]:
raise serializers.ValidationError("start_date must be before end_date.")
return data # ← return the whole dict
validate_<field> sees one field; validate sees them all. So "is this price positive?" goes in validate_price, but "does the discount not exceed the price?" (which needs two fields) goes in validate.
Errors raised here land under the non_field_errors key (since they're not about one specific field), which is why you sometimes see errors show up there instead of next to a field.
The Rule That Falls Out of the Order
Once you see the sequence, the "where do I put this check?" question answers itself every time:
One field, on its own? →
validate_<fieldname>(orvalidators=[...]on the field).
Two or more fields together? → the object-levelvalidate(self, data).
And the ordering guarantees something useful. By the time your validate(self, data) runs, every individual field has already been checked and type-converted. Inside validate you can trust that data["price"] is a real, valid number. You don't have to re-check whether it's present or numeric.
Each level cleans up for the next. That's the elegance of the pipeline: later stages build on the guarantees of earlier ones.
The PATCH Trap That Breaks Cross-Field Validation
Here's the gotcha that catches even experienced DRF developers, and it's worth its own section because it causes real production bugs.
Your validate(self, data) works perfectly on POST and full PUT requests. Then someone sends a partial update (PATCH) and it crashes with a KeyError.
Why? On a partial update, the data dictionary passed to validate() only contains the fields the request actually included. If someone PATCHes just the start_date, then data["end_date"] isn't there, and your data["start_date"] > data["end_date"] blows up on the missing key.
The fix is to not assume both fields are present. Fall back to the existing instance for any field the request didn't send:
def validate(self, data):
# On PATCH, a field may be absent: fall back to the current instance
start = data.get("start_date", getattr(self.instance, "start_date", None))
end = data.get("end_date", getattr(self.instance, "end_date", None))
if start and end and start > end:
raise serializers.ValidationError("start_date must be before end_date.")
return data
The rule: any cross-field check in
validate()must handle the case where a field is missing, because on PATCH it legitimately will be. Usedata.get(...)with a fallback toself.instance, neverdata["field"].
This single habit prevents a whole category of "works on create, 500s on edit" bugs.
Where validated_data Comes From (and Why Transformations Stick)
One more piece that ties it together. Notice that both validate_<field> (returns a value) and validate (returns the dict) return their data. That's not ceremony. Those return values are what build validated_data.
Each level can transform the data on its way through: normalize an email to lowercase in validate_email, strip whitespace, coerce a format. Because you return the transformed value, that cleaned version is what ends up in validated_data and gets saved.
So the pipeline isn't just a gate that says yes/no. It's also a cleaning line that hands you normalized, trustworthy data at the end. Whatever comes out of validated_data is the product of every transformation each level applied.
This is also why forgetting to return is so destructive: you drop the value out of the cleaning line entirely.
Why Understanding This Makes You Better at DRF
Validation bugs in DRF are almost never "the framework is broken." They're "the check was in the wrong level, or assumed a field was present when it wasn't."
The developer who knows the pipeline puts single-field rules in validate_<field>, cross-field rules in validate, remembers to return, and writes validate defensively so PATCH doesn't break it.
The developer who doesn't scatters checks semi-randomly, wonders why some never fire (they were on a field that failed Level 1), and ships an endpoint that validates correctly on create and crashes on edit.
And the model is genuinely small:
-
is_valid()opens the gate. - Data flows through field validators →
validate_<field>→validate(), in that order, each level trusting the one before. - Single-field logic goes in
validate_<field>; cross-field logic goes invalidate. - Every method must return its data, because that's what builds
validated_data. -
validate()must tolerate missing fields, because PATCH sends partial data.
Hold that, and DRF validation stops being a place you copy-paste from examples and becomes a pipeline you can reason about. You'll always know exactly where a check belongs, and exactly why the misplaced one isn't running.
The 30-Second Takeaway
-
Serializer validation runs when you call
is_valid().Truegives youvalidated_data(clean, type-converted, only available after a passingis_valid()).Falsegives youerrors.is_valid(raise_exception=True)auto-returns an HTTP 400. -
Three levels fire in this exact order:
- Field deserialization + field validators
-
validate_<fieldname>()methods - The object-level
validate(self, data)
-
validate_<field>(self, value)holds rules for one field. It runs only if the field passed Level 1, and mustreturn value(forgetting the return silently nulls the field). -
validate(self, data)holds cross-field rules (needs multiple fields at once). It runs after all fields pass individually, and its errors land undernon_field_errors. It mustreturn data. -
Where to put a check: one field →
validate_<field>; two or more fields →validate. By the timevalidateruns, every field is already validated and converted, so you can trust them. -
The PATCH trap: on a partial update,
validate()'sdatadict only contains the sent fields, sodata["other_field"]raisesKeyError. Usedata.get(field, getattr(self.instance, field, None)), neverdata[field]. -
Every level returns its data because those returns build
validated_data. The pipeline transforms and normalizes, not just gates.
Your Turn
Open your biggest serializer and check two things:
- Does every
validate_method actually return its value? - Does your
validate()usedata.get()instead ofdata[...]?
Those two habits prevent the most common DRF validation bugs before they ever reach production.
Which DRF validation bug has bitten you hardest? Tell me in the comments 👇
If this was useful, follow me here for more stories from the backend trenches, and subscribe to The Backend Tech Lead on Substack for weekly blueprints.
Top comments (0)