DEV Community

Cover image for How DRF Serializer Validation Actually Runs (and the Order That Trips Everyone)
Anas Issath
Anas Issath

Posted on Originally published at levelup.gitconnected.com

How DRF Serializer Validation Actually Runs (and the Order That Trips Everyone)

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)
Enter fullscreen mode Exit fullscreen mode

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 get serializer.validated_data: a dictionary of clean, checked, type-converted values, safe to build or update an object with. This is the only time validated_data exists. You cannot access it before is_valid() has run and passed.
  • If it returns False, you get serializer.errors: a dictionary mapping each field name to a list of error messages (plus a special non_field_errors key, 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Two things people forget here, both important:

  • You must return value. The method's return becomes the field's entry in validated_data. Forget the return and the field silently becomes None, a classic, maddening bug.
  • It only runs if the field passed Level 1. If title failed its built-in CharField check (or a required field was missing), validate_title is never called. So a check you put in validate_title won'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
Enter fullscreen mode Exit fullscreen mode

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> (or validators=[...] on the field).
Two or more fields together? → the object-level validate(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
Enter fullscreen mode Exit fullscreen mode

The rule: any cross-field check in validate() must handle the case where a field is missing, because on PATCH it legitimately will be. Use data.get(...) with a fallback to self.instance, never data["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 in validate.
  • 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(). True gives you validated_data (clean, type-converted, only available after a passing is_valid()). False gives you errors. is_valid(raise_exception=True) auto-returns an HTTP 400.
  • Three levels fire in this exact order:
    1. Field deserialization + field validators
    2. validate_<fieldname>() methods
    3. 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 must return 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 under non_field_errors. It must return data.
  • Where to put a check: one field → validate_<field>; two or more fields → validate. By the time validate runs, every field is already validated and converted, so you can trust them.
  • The PATCH trap: on a partial update, validate()'s data dict only contains the sent fields, so data["other_field"] raises KeyError. Use data.get(field, getattr(self.instance, field, None)), never data[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:

  1. Does every validate_ method actually return its value?
  2. Does your validate() use data.get() instead of data[...]?

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)