---
title: "Validating the Schema at the Edge of the Queue"
description: "A message's data is an untyped contract the queue won't check. Validate it at the edge: producer before publish, consumer as a safety net."
url: https://sade.dev/en/notes/validate-schema-at-the-edge/
lang: en
author: "Muhammet Şafak"
published: 2026-06-18
updated: 2026-09-06
section: Note
tags: ["queue","schema","reliability","architecture"]
summary: "A broker moves bytes and never looks inside the payload, so the type safety you have inside one service evaporates the moment the data crosses the wire. Validate at both edges, but know which one pays: producer-side, before publish, fails synchronously in the service that actually has the bug and catches it once. Consumer-side is the safety net that dead-letters a poison message from a producer you do not control."
---

# Validating the Schema at the Edge of the Queue

> A broker moves bytes and never looks inside the payload, so the type safety you have inside one service evaporates the moment the data crosses the wire. Validate at both edges, but know which one pays: producer-side, before publish, fails synchronously in the service that actually has the bug and catches it once. Consumer-side is the safety net that dead-letters a poison message from a producer you do not control.

A worker started throwing `KeyError` in production. The cause wasn't its code — an upstream service had shipped a change that dropped a field from the event's payload, and the queue had carried the malformed message across without a word. The bug wasn't the bad message. It was that nothing checked it at the boundary.

## The queue carries bytes, not types

A request body hits a typed handler; a framework rejects it at the door if it's the wrong shape. A queue gives you none of that. The payload is opaque JSON — the broker moves bytes and never looks inside. Whatever the producer put in, the consumer gets, valid or not. The type safety you have inside one service evaporates the moment the data crosses the wire.

So if you want a guarantee about a message's shape, you have to add it yourself — at the **edge**, where data enters and where it leaves.

## Validate where data enters, and where it leaves

There are two edges, and they catch different failures:

- **Producer-side, before publish — the primary one.** Validate the payload as you produce it. Invalid data never enters the queue, the failure surfaces *synchronously* in the service that actually has the bug, and it's caught once — before the message fans out to every consumer. This is the cheap place to fail.
- **Consumer-side, on receive — the safety net.** Validate again as you consume. This catches what producer-side can't: messages from producers you don't control — another team, an older deployed version, a hand-crafted replay. A message that fails here is a poison message; route it to a dead-letter queue rather than letting it crash the handler in a loop.

A subtlety worth knowing: most queue runtimes have no "reject immediately" hook, so a consumer-side rejection usually *retries* before it dead-letters — and invalid data never becomes valid on retry. That's wasted work, which is exactly why producer-side is where the value is. Consumer-side is the seatbelt, not the steering.

## The schema lives with the message, not the envelope

The thing you validate against is a schema *per message type* — keyed by the event's identity, not bolted onto the transport envelope. Keep it in a registry that versions independently, and the same schema you [evolve carefully without breaking consumers](/en/journal/schema-evolution-without-breaking) becomes the schema you enforce at runtime. One contract, checked at change-time (does this edit break anyone?) and at runtime (does this message obey it?).

This is the structural cousin of [making a duplicate delivery a no-op](/en/notes/idempotency-duplicate-delivery): both accept that you don't control what arrives, and put the guarantee in your own boundary instead of trusting the sender.

## When is it not worth it?

A single service's internal queue — one producer, one consumer, deployed together — doesn't need this. The type system already spans both ends; edge validation is ceremony. It starts paying the moment a *second*, independently-deployed producer or consumer exists — a different team, a different language, a different release cadence. That's also the moment the untyped payload quietly becomes your most fragile contract.

---

*See also:* the [BabelQueue schema-validation spec](https://babelqueue.com/docs/spec/1.x/schema-validation) defines the per-URN schema and where it's enforced, and the [babelqueue-registry](https://github.com/BabelQueue/babelqueue-registry) holds those schemas — its `bqschema` tool is what runs the check.

## Frequently asked

**Should you validate on the producer side or the consumer side?**

Producer-side is the primary one: invalid data never enters the queue, the failure surfaces synchronously in the service that has the bug, and it is caught once before the message fans out to every consumer. Consumer-side is the safety net for producers you do not control: another team, an older deployed version, a hand-crafted replay.

**What do you do with a message that fails validation on the consumer side?**

Treat it as a poison message and route it to a dead-letter queue instead of letting it crash the handler in a loop. Invalid data never becomes valid on retry, so retrying it is wasted work.


## Sources

- [JSON Schema Validation 2020-12](https://json-schema.org/draft/2020-12/json-schema-validation) — JSON Schema
- [Dead Letter Exchanges: when a message is dead-lettered](https://www.rabbitmq.com/docs/dlx) — RabbitMQ
