Crabrix blog
Rust Serde and JSON: build a trustworthy data boundary
Parse JSON with Serde, validate domain rules, handle missing fields and null, and test evolving Rust APIs with bounded inputs and useful diagnostics.
· 11 min read · Updated
Crabrix blog
Parse JSON with Serde, validate domain rules, handle missing fields and null, and test evolving Rust APIs with bounded inputs and useful diagnostics.
· 11 min read · Updated
A JSON document can be perfectly valid and still describe an impossible operation. A project import might contain an empty name, twenty million tasks, a negative revision, or a deadline that precedes its creation. Deserialization answers whether bytes fit a representation. Your application must separately decide whether the represented request is allowed. Treating these as different questions makes Rust APIs easier to change and substantially easier to test.
This guide builds a small task-import boundary: bounded text enters, Serde decodes a transport structure, validation produces a domain value, and only then does application work begin. The same design fits configuration files, HTTP request bodies, browser messages and saved documents. Nothing here needs a running web server. The examples use explicit dependency versions and ordinary local Cargo; their purpose is to make data contracts visible rather than hide every failure behind a generic parsing message.

Create a normal binary package and put the following dependencies in Cargo.toml. The derive feature generates trait implementations for your own types; serde_json supplies the JSON format. These pinned versions are the versions used for this article, not a claim that every project should freeze them forever. Keep the generated Cargo.lock with an application and review dependency updates through the same tests as source changes.
Each Rust block below is a complete, independent main program for this manifest. Replace src/main.rs with one block at a time and run cargo run; do not concatenate their repeated types and main functions. The examples deliberately keep transport concerns small enough to inspect. A production endpoint also needs authentication, authorization and request-level resource limits, but none of those should be confused with whether a string can become a Rust structure.
[package]
name = "json-boundary-lab"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "=1.0.228", features = ["derive"] }
serde_json = "=1.0.145"Sources: Serde derive setup
Explore in Crabrix: Dependency kinds · Lockfile reproducibility
The first program separates ImportRequest from TaskBatch. The request describes a required title and a list of external task strings. The validated type owns only accepted values. Conversion checks the title after trimming, counts tasks, and rejects empty task names before returning anything. Because validation builds a new vector, failure cannot leave a half-imported project behind. That small transactional property is often more valuable than a clever attribute.
The byte check happens before deserialization and measures UTF-8 bytes, not the number of displayed characters. It bounds this already-buffered input; it does not retroactively prevent a network layer from buffering an enormous request. Apply the corresponding limit while receiving or reading data too. The explicit limits here are example product decisions. Pick real limits from your storage, responsiveness and abuse budgets, then keep boundary cases in tests.
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ImportRequest {
title: String,
tasks: Vec<String>,
}
#[derive(Debug, PartialEq)]
struct TaskBatch {
title: String,
tasks: Vec<String>,
}
fn decode(input: &str) -> Result<TaskBatch, String> {
if input.len() > 16_384 {
return Err("input exceeds 16 KiB".into());
}
let wire: ImportRequest = serde_json::from_str(input)
.map_err(|e| format!("JSON at {}:{}: {e}", e.line(), e.column()))?;
let title = wire.title.trim().to_owned();
if title.is_empty() || title.chars().count() > 80 {
return Err("title must contain 1..=80 Unicode scalar values".into());
}
if wire.tasks.len() > 100 {
return Err("at most 100 tasks are accepted".into());
}
let mut tasks = Vec::with_capacity(wire.tasks.len());
for (index, task) in wire.tasks.into_iter().enumerate() {
let task = task.trim().to_owned();
if task.is_empty() {
return Err(format!("tasks[{index}] is empty"));
}
tasks.push(task);
}
Ok(TaskBatch { title, tasks })
}
fn main() {
let good = r#"{"title":" Rust lab ","tasks":["read","test"]}"#;
let batch = decode(good).expect("valid fixture");
assert_eq!(batch.title, "Rust lab");
assert_eq!(batch.tasks, ["read", "test"]);
assert!(decode(r#"{"title":" ","tasks":[]}"#).is_err());
assert!(decode(r#"{"title":"lab","tasks":[""]}"#).is_err());
assert!(decode(r#"{"title":"lab","tasks":[],"typo":1}"#).is_err());
assert!(decode(&" ".repeat(16_385)).is_err());
println!("validated {} tasks", batch.tasks.len());
}Sources: Typed JSON decoding
An ignored field can be harmless forward compatibility or a dangerous typo. A user editing a small configuration may expect retrys to fail rather than quietly leave retries at its default. A client consuming a public API may instead need to tolerate a new descriptive field introduced by the server. Strictness is therefore a boundary decision, not a universal definition of good JSON. The task-import example deliberately chooses a strict request contract.
Serde ignores unknown JSON fields by default for ordinary derived structures; deny_unknown_fields asks it to reject them. Avoid combining that policy with flatten: the documented combination is unsupported. If you need an extension map, design the accepted namespace and size budget explicitly. Do not silently add a catch-all map merely to make a failing fixture pass. Write down which producer changes must remain compatible and which require a versioned migration.
Sources: Container attributes and unknown fields · Flatten and field attributes
For an Option field in an ordinary derived struct, both a missing key and an explicit JSON null normally deserialize to None. An empty string is still Some containing an empty string. That behavior is convenient for optional profile text, but it loses information needed by many patch endpoints: omitted may mean leave unchanged, while null may mean clear the value. Decide those semantics before selecting the field type.
The following small inspection program makes the distinction without pretending a nested Option automatically solves it. Value::get returns no entry for an absent key and a present Null for explicit null. The example also rejects a number instead of manufacturing a default string. For a larger patch schema, use a deliberately designed presence type or a well-tested custom deserializer, and keep the public wire examples beside its tests.
use serde_json::{Value, json};
fn describe(value: &Value) -> Result<&'static str, &'static str> {
if !value.is_object() {
return Err("patch must be an object");
}
match value.get("nickname") {
None => Ok("unchanged"),
Some(Value::Null) => Ok("clear"),
Some(Value::String(s)) if s.is_empty() => Ok("empty string"),
Some(Value::String(_)) => Ok("replace"),
Some(_) => Err("nickname must be a string or null"),
}
}
fn main() {
assert!(describe(&json!(7)).is_err());
assert_eq!(describe(&json!({})), Ok("unchanged"));
assert_eq!(describe(&json!({"nickname":null})), Ok("clear"));
assert_eq!(describe(&json!({"nickname":""})), Ok("empty string"));
assert_eq!(describe(&json!({"nickname":"crab"})), Ok("replace"));
assert!(describe(&json!({"nickname":7})).is_err());
println!("all presence cases checked");
}Sources: Value access and JSON variants
Imagine an import report with four distinct results: too large, malformed representation, invalid business request, and accepted batch. A user can act on each one differently. Retrying an oversized file without editing it is pointless. Fixing a missing bracket will not repair an empty project title. Keeping these outcomes separate also makes support logs useful without recording the entire document.
The diagram is a conceptual pipeline rather than a promise about compiler internals or allocation counts. In your own code, a typed error enum can carry a category and a safe field path while preserving the original parser error as a source. The example uses String to keep the complete program compact. If callers need to branch on categories, replace those strings with variants before the boundary becomes a public library API.

Explore in Crabrix: Error design · Useful Result boundaries
A request with a kind string and five unrelated optional fields invites impossible combinations. A tagged enum lets the representation express which fields belong to each operation. In the example, rename carries a title while archive carries no data. Callers handle the decoded operation with an exhaustive match. There is no branch that accidentally archives because a string comparison used the wrong capitalization.
Tagged representation is still a protocol choice. The default external representation, an internal tag, and an adjacent tag produce different JSON shapes. Here an adjacent kind/content pair keeps the payload boundary obvious. Renaming variants changes the wire contract unless you provide a deliberate compatibility path. Keep old request fixtures when evolving an API, and verify the actual serialized shape rather than assuming a Rust rename is a private refactor.
use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
enum Command {
Rename { title: String },
Archive,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let input = r#"{"kind":"rename","data":{"title":"Crab lab"}}"#;
let command: Command = serde_json::from_str(input)?;
match &command {
Command::Rename { title } => assert_eq!(title, "Crab lab"),
Command::Archive => panic!("wrong fixture variant"),
}
let encoded = serde_json::to_string(&command)?;
assert_eq!(serde_json::from_str::<Command>(&encoded)?, command);
assert!(serde_json::from_str::<Command>(r#"{"kind":"remove"}"#).is_err());
println!("{encoded}");
Ok(())
}Sources: Serde enum representations
Explore in Crabrix: Enums and state machines
Borrowed strings can avoid copying some fields when deserializing from retained text. They also make the output depend on that text remaining alive. A structure borrowing from a temporary response buffer cannot simply be returned after the buffer is destroyed. This is a useful ownership constraint, not a compiler inconvenience to bypass with unsafe code or leaked memory.
JSON escaping adds another wrinkle: a string containing escapes may need decoded storage rather than a direct slice of the original bytes. Do not advertise every borrowed representation as universally zero-copy. Start with owned String fields when records must survive independently, enter a cache, or cross a task boundary. Optimize a measured hot boundary later, with fixtures containing escaped characters and non-ASCII text, and include retained input memory in the comparison.
Sources: Deserializer lifetimes and borrowing
Explore in Crabrix: Owned or borrowed data · UTF-8 parsing boundaries
A stream of separate JSON objects is different from one JSON array containing many objects. StreamDeserializer can iterate successive top-level values; it does not automatically turn every large array into an element-by-element application protocol. Decide whether your format is a single document, newline-delimited records, or another framed sequence before choosing a reader. Producers and consumers must agree where a record ends.
The program below parses two separate objects and validates their shape. It still holds the small input string in memory, so it demonstrates record iteration rather than a bounded network transport. A real import needs limits on individual records, total records and processing work. A malformed record also needs an explicit stop-or-skip policy; skipping arbitrary bytes until another brace appears is not reliable JSON recovery.
use serde::Deserialize;
#[derive(Deserialize)]
struct Record {
id: u64,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let input = r#"{"id":1}
{"id":2}
"#;
let records = serde_json::Deserializer::from_str(input).into_iter::<Record>();
let mut ids = Vec::new();
for record in records {
ids.push(record?.id);
}
assert_eq!(ids, [1, 2]);
println!("read {} records", ids.len());
Ok(())
}Sources: Successive JSON values
The JSON error type exposes categories and line/column positions. Those help distinguish malformed syntax, unexpected data and incomplete input, but a line number is not a complete business-field path. Preserve the diagnostic for developers while giving users a precise, stable explanation where possible. Never promise that every failure can be retried: a truncated transport, a permanent schema mismatch and an authorization refusal require different responses.
A byte ceiling does not bound every cost. Nested structures, output collections, repeated validation and large numeric values can impose additional work. Keep the parser's normal recursion protection; disabling it requires a separately justified strategy. Avoid logging raw credentials or complete imported documents just because decoding failed. Record safe context such as format version, a generated request identifier and the rejected rule, with payload access reserved for an explicit debugging workflow.
Sources: JSON error categories and positions · Deserializer recursion controls
Decoding a JSON number into u64 checks one representation boundary. It does not establish that the value is a sensible timeout, an affordable allocation or a valid monetary amount. A timeout of zero and a timeout of fifty years may both fit the integer type. Convert transport values into domain types that carry the accepted range and make the unit visible in names such as timeout_ms. Validate before converting into platform-sized allocation counts.
Large identifiers also cross language boundaries. A JavaScript consumer cannot represent every u64 integer exactly as an ordinary Number. If the protocol must preserve such identifiers, agree on a string representation and validate its format explicitly on both sides. Avoid routing exact money through floating-point arithmetic just because JSON supports numbers. State the currency and integer minor-unit policy, or choose a documented decimal representation. Round-trip tests should include the largest accepted value and one beyond it.
Explore in Crabrix: Numeric safety and conversions
A default changes what an absent field means. Adding default to a required permission or safety limit can accidentally turn a rejected old request into an accepted new one. Pick the missing-field behavior as a product decision, then encode it. For a harmless display preference, a default may be appropriate; for the identity of the object being edited, requiring an explicit value is often clearer. Do not use defaults simply to silence a failing import.
An alias can accept an old field spelling while serialization emits the new spelling. Keep fixtures for each supported spelling and a conflict case containing both names. This is especially useful when a saved-document format outlives the application version that first wrote it. Treat aliases and defaults as documented migration behavior, with a retirement policy where needed, rather than accumulating a permanent collection of unexplained exceptions in the struct definition.
Sources: Field defaults, aliases and renaming
When a format changes substantially, give its envelope an explicit version and migrate old representations into the current domain model. Test the old input, the migrated value and the rejection of unsupported future versions. Migration should not quietly reinterpret a string identifier as a numeric array index or discard a field that still affects behavior. If information is intentionally dropped, explain that choice and preserve the original document when recovery matters.
For the task-import lab, a useful extension is a version-two task object containing text and completed status. Write a fixture for the original string array, convert every old item into a pending task, and compare the resulting domain batch with an equivalent version-two fixture. Keep that conversion pure. Saving or importing the migrated batch should remain a separate step so a failed later validation cannot overwrite the only usable copy.
A round trip proves that your current serializer and deserializer agree on one value. It does not prove that old saved documents still load or that an older client understands new output. Add golden input fixtures for supported historical versions, then assert the validated meaning. Test required-field removal, field aliases, unknown variants, wrong scalar types, missing versus null, whitespace-only titles and the exact size boundary.
Also test that rejection happens before effects. In the import example, the useful assertion is that an invalid third task creates no first or second task. Keep decoding and validation pure so that this is easy to establish. If persistence is separate, make the accepted TaskBatch the input to one deliberate operation. This keeps malformed external data from becoming partially committed application state and makes later retry behavior much easier to reason about.

Explore in Crabrix: Testing boundaries and fixtures · Executable documentation
These examples are local data transformations. On desktop Rust, run them with the manifest above and keep the assertions. In Crabrix, dependency preparation and procedural-macro support depend on the app's qualified compiler profile: Serde derive is a procedural macro, so a successful standard-library example does not establish that this dependency graph will build there. Inspect the actual compatibility report rather than substituting a claim based on the crate name.
The portable lesson is the boundary design. You can practice domain validation with a manually constructed transport structure even when a particular derive graph is unavailable. When you add a new accepted field, specify its meaning, default and migration behavior, add a failing fixture first, and rerun the complete import path. That routine gives a JSON API a clear contract as it grows, without requiring a server, a framework or an opaque validation layer to explain its behavior.
Explore in Crabrix: Crabrix local execution guide · Structs · Option and Result