Track 3 / Guide 15
Working with JSON in Rust and Serde
Use Serde derives, borrowed strings, explicit ownership, custom serialization, and bounded readers to build reliable Rust JSON boundaries.
On this page
Let types express the contract you intend
Serde connects Rust types to serialization formats through traits and derives. A typed request model makes expected fields and value types explicit, reducing ad hoc inspection of generic JSON values. It does not replace authorization, business validation, or resource limits. A successfully deserialized quantity can still violate inventory rules, and a valid identifier can still belong to another tenant.
Use a generic value when the application genuinely needs arbitrary structure, not as the default intermediary for every request. Converting from generic values into typed objects adds work and can obscure where validation occurs. Conversely, forcing a highly variable document into a rigid struct can create awkward escape hatches. Choose the representation according to the boundary's semantics.
Distinguish optionality, defaults, and null behavior deliberately. Derive attributes can change acceptance and output behavior, so review them as contract decisions rather than cosmetic annotations. The JSON specification guide explains representation issues such as numeric range and missing values that remain relevant even when Rust's type system prevents many memory errors.
Borrowed strings have strict input-lifetime requirements
Deserializing a borrowed string can avoid copying when the input already contains a directly usable slice. The resulting value cannot outlive the backing input. Rust's lifetime rules make that relationship explicit, which is the central safety property of this optimization. Do not attempt to return a borrowed record from a function that creates and drops its own input buffer.
Escaped JSON strings complicate borrowing because decoding may require constructing new text. A plain borrowed string field cannot represent every escaped input without storage elsewhere. Use an owned string when general input support and independent lifetime are needed, or a carefully tested borrowing-capable design that can fall back to ownership. Zero-copy is conditional, not a guarantee attached to every derive.
Reader-based deserialization generally cannot lend stable slices from an ephemeral read buffer to an arbitrarily long-lived result in the same way as parsing a retained string or byte slice. Select the API according to ownership needs. A tiny retained borrowed field can keep a very large source allocation alive, so fewer copies may increase retained memory. Measure the lifetime of the entire backing buffer, not just the size of the field.
Runnable owned and borrowed models
Create a new Cargo binary project and add serde with the derive feature plus serde_json. The program below compares a borrowed record from an unescaped input with an owned record containing an escaped string. It also implements a small custom serializer for an identifier that must be emitted as a JSON string, preserving the contract across consumers with different integer precision.
The borrowed example keeps its input in scope until the value is no longer used. The owned example can contain decoded escape content because it allocates its own string. The failed borrowed parse is intentional and illustrates an input-dependent limitation rather than a memory-safety failure. Keep such fixtures in tests before describing a path as zero-copy.
The custom serializer delegates the final escaping to Serde's serializer instead of constructing JSON manually. Production identifiers may need additional parsing and domain validation, but the output representation is explicit. Avoid using custom serialization to hide undocumented type changes. Consumers should know that the identifier is a string and should not perform arithmetic on it.
use serde::{Deserialize, Serialize, Serializer};
#[derive(Debug, Deserialize)]
struct Borrowed<'a> {
name: &'a str,
}
#[derive(Debug, Deserialize)]
struct Owned {
name: String,
}
struct Identifier(u64);
impl Serialize for Identifier {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.0.to_string())
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let input = String::from(r#"{"name":"Ada"}"#);
let borrowed: Borrowed<'_> = serde_json::from_str(&input)?;
assert_eq!(borrowed.name, "Ada");
let escaped = r#"{"name":"A\u0064a"}"#;
let owned: Owned = serde_json::from_str(escaped)?;
assert_eq!(owned.name, "Ada");
assert!(serde_json::from_str::<Borrowed<'_>>(escaped).is_err());
let encoded = serde_json::to_string(&Identifier(9007199254740993))?;
assert_eq!(encoded, "\"9007199254740993\"");
println!("{}", encoded);
Ok(())
}Use custom serializers for semantics, not cleverness
Custom serialization is appropriate when a domain type needs a stable wire representation, such as an identifier, decimal amount, or constrained timestamp. Keep the conversion local to the type and document the external form. A custom implementation should preserve invariants rather than quietly coerce invalid state into a convenient output.
For deserialization, validate the accepted representation and return a useful error when it violates the contract. Avoid accepting many ambiguous forms merely to be permissive. Supporting both numbers and strings for the same field can complicate generated clients and make round-trip behavior unpredictable. If compatibility requires multiple forms, make the transition explicit and test each one.
Do not write JSON punctuation by hand inside a serializer. Use the provided interfaces so escaping and structural rules remain the format implementation's responsibility. Keep domain validation separate from transport formatting where that improves clarity. A type that represents only valid values can simplify later serialization, but constructing that type still requires checks that the derive system cannot infer from business requirements.
Streaming and ownership solve different problems
Borrowing reduces some copies for retained input, while streaming reduces the need to retain the entire input. These goals can conflict. A streaming reader commonly produces owned records that can outlive its current buffer, whereas borrowing from a complete source keeps that source alive. Choose based on the expected record size and processing lifecycle.
For a large array, a convenience call that returns a vector still materializes all elements even if the parser reads incrementally internally. Use an appropriate visitor or record-framed input when the application must process bounded records. For independent JSON values, understand the stream deserializer's framing and error behavior rather than assuming it automatically implements every NDJSON policy.
Keep downstream queues and aggregates bounded. Rust's memory safety does not prevent an application from allocating an unbounded vector or map. The streaming pipeline guide discusses these architecture choices independently of language. The same principle applies in Rust: ownership makes retained data explicit, but developers still decide how much data remains owned at once.
Handle errors without turning input into diagnostics
Return deserialization errors through a controlled boundary rather than panicking on untrusted input. Include a stable client-facing category and, where useful, a bounded location. Avoid echoing the entire document or sensitive values. A parse failure is an expected invalid-input outcome, not necessarily an exceptional process failure.
Separate syntax errors from domain validation failures and authorization failures. This makes monitoring useful and prevents a generic error wrapper from leaking internal details. For batch ingestion, decide whether one bad record stops the job or enters a protected quarantine path. Do not silently drop records merely because a stream iterator returns an error.
Test deeply nested input and oversized values under the selected library configuration. Keep library safety limits unless the application has a measured reason and additional protections for changing them. Disabling recursion limits to make a large fixture pass can move failure into stack exhaustion elsewhere. Bound transport bytes and processing concurrency as well as parser behavior.
Benchmark allocations and lifetime together
Compare owned and borrowed models on both escaped and unescaped inputs. A benchmark containing only simple ASCII strings can overstate the benefit available for real data. Include a realistic distribution of string lengths, optional fields, arrays, and Unicode. Verify that every candidate produces equivalent values before timing it.
Measure peak and retained memory after processing, not only allocation counts during parsing. Borrowing a small field from a huge buffer can keep the huge buffer live through a cache or asynchronous operation. An owned copy may release the original sooner and reduce the application's steady-state footprint. This is an example where fewer copies do not necessarily mean less memory.
Keep parser time, domain conversion, and serialization separate in component benchmarks, then combine them in an end-to-end path. The Go performance guide uses the same discipline for typed versus generic models. Cross-language comparisons should include equivalent work and ownership duration rather than contrasting a borrowed view in one language with a fully independent object graph in another.
Maintain a compatibility corpus around derive changes
Attributes that rename fields, omit values, apply defaults, or reject unknown members can alter public behavior. Review them with the same care as a schema change. Add fixtures for absent fields, explicit null, zero values, unknown properties, and alternate enum variants. A refactor that compiles cleanly can still break a serialized contract.
Pin dependencies through the lock file for reproducible applications and run the corpus on upgrades. Keep fixtures for escaping and numeric boundaries, especially when custom serializers or feature flags are involved. Verify output as parsed values and, where signatures require it, as exact bytes under a documented canonicalization policy.
Finally, document ownership assumptions near interfaces that return borrowed data. A caller should know whether retaining a result also retains a large source buffer. Prefer a straightforward owned model when the performance benefit of borrowing is unmeasured or the lifecycle crosses task boundaries. Rust makes both choices safe when used correctly; engineering judgment determines which choice is simpler and more efficient for the actual workload.
Choose owned boundaries for long-lived tasks
Borrowing works best when processing remains within the lifetime of a retained input buffer. Once a value is queued, cached, or sent into a long-lived task, ownership often becomes simpler. Converting the few required fields into owned values can release a large source buffer earlier and make task boundaries easier to understand.
Do not reach for unsafe lifetime extensions to force a borrowed model into an incompatible architecture. The compiler's rejection is identifying a real ownership mismatch. Redesign the boundary or use an owned representation. A small allocation is preferable to an invalid reference or an undocumented global buffer lifetime.
If using a borrowing-capable wrapper that can own escaped content, test both modes explicitly. Confirm that the chosen deserialization implementation actually borrows in the intended case rather than assuming it from the type name. Derive behavior, attributes, and library adapters can affect whether a copy occurs.
Document whether callers may retain the result and what that retention costs. This is part of the API's performance contract, especially when a small result keeps a large input allocation reachable. An owned boundary can be the more memory-efficient design even when a local parse benchmark shows more allocations.
Review custom wire types with round-trip properties
For every custom serializer, define the accepted input form and emitted output form. They need not be identical if the system intentionally normalizes data, but the transformation should be explicit. A decimal type might preserve scale or normalize it; an identifier should remain opaque; a timestamp should define timezone and precision.
Write round-trip tests around domain values rather than only example strings. Include minimum and maximum values, empty or missing representations where allowed, and invalid spellings. Ensure deserialization rejects values outside the domain instead of constructing an invalid internal state that later serialization must repair.
Keep human readability and machine fidelity separate. Display formatting may include separators or localized text that should never become the wire representation. Use a dedicated serializer rather than reusing a general display implementation unless that implementation is explicitly the protocol contract.
Test errors without panics. A custom visitor should return the format's error mechanism for unsupported input and avoid unbounded recursion or allocation. The type system prevents many memory mistakes, but application code still controls resource use and error propagation.
When changing a custom representation, treat it as a versioned contract migration. Old stored messages and clients may rely on the previous spelling or type. Preserve compatibility fixtures and document the transition. A serializer refactor that produces different JSON is externally observable even if all Rust call sites continue compiling.
Include a compile-time example in project documentation showing the intended input lifetime, and a runtime test for escaped strings. These protect different guarantees: the compiler enforces reference validity, while the fixture verifies accepted wire data. Neither test should be treated as a substitute for the other.
Engineering Comparison
| Approach | Allocation behavior | Lifetime constraint | Best use |
|---|---|---|---|
| Owned String | Stores decoded text | Independent of source | General API records |
| Borrowed &str | Can avoid a copy | Cannot outlive input | Unescaped retained input |
| Generic Value | Builds generic structure | Owns representation | Dynamic documents |
| Typed derive | Known field layout | Depends on field types | Stable contracts |
| Reader to owned model | No whole-source ownership required | Model owns its values | File and network inputs |
| Custom serializer | Domain-dependent | Implementation-specific | Explicit wire semantics |
Zero-copy deserialization is conditional on input spelling and lifetime. Memory safety does not itself bound allocation or prevent application-level denial of service.