What are the implications of using #[serde(deny_unknown_fields)] for forward compatibility in APIs?
#[serde(deny_unknown_fields)] configures a struct to reject any JSON object containing fields that don't have corresponding struct fields, rather than silently ignoring them. This creates a trade-off for API evolution: it prevents silent data loss when clients send new fields that older API versions don't recognize, but it also breaks backward compatibility when the API adds new fields that older clients didn't expect. Without this attribute, unknown fields are silently dropped during deserialization, which can hide bugs but enables forward compatibility. The choice depends on your API versioning strategy: strict validation catches client errors early but requires careful version management, while permissive parsing enables smoother API evolution but risks silent data loss.
Default Behavior: Ignoring Unknown Fields
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct User {
name: String,
email: String,
}
fn main() {
let json = r#"{
"name": "Alice",
"email": "alice@example.com",
"role": "admin",
"department": "engineering"
}"#;
// Unknown fields are silently ignored
let user: User = serde_json::from_str(json).unwrap();
println!("{:?}", user);
// User { name: "Alice", email: "alice@example.com" }
// role and department are discarded
}By default, Serde silently discards any fields not defined in the struct.
Enabling Strict Validation
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct User {
name: String,
email: String,
}
fn main() {
let json = r#"{
"name": "Alice",
"email": "alice@example.com",
"role": "admin"
}"#;
// This will fail because "role" is unknown
let result: Result<User, _> = serde_json::from_str(json);
match result {
Ok(user) => println!("{:?}", user),
Err(e) => println!("Error: {}", e),
// Error: unknown field `role`, expected `name` or `email`
}
}deny_unknown_fields rejects any unrecognized fields with a clear error message.
Forward Compatibility Problem
use serde::{Deserialize, Serialize};
// Version 1.0 of API
#[derive(Deserialize, Serialize, Debug)]
#[serde(deny_unknown_fields)]
struct ConfigV1 {
timeout: u32,
retries: u32,
}
// Version 2.0 adds new field
#[derive(Deserialize, Serialize, Debug)]
#[serde(deny_unknown_fields)]
struct ConfigV2 {
timeout: u32,
retries: u32,
cache_ttl: u32, // New field in v2
}
fn main() {
// Server sends v2 response to v1 client
let v2_response = r#"{
"timeout": 30,
"retries": 3,
"cache_ttl": 300
}"#;
// v1 client with deny_unknown_fields fails!
let result: Result<ConfigV1, _> = serde_json::from_str(v2_response);
assert!(result.is_err());
// Error: unknown field `cache_ttl`
}With deny_unknown_fields, adding new fields breaks older clients.
Without deny_unknown_fields
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Debug)]
struct ConfigV1 {
timeout: u32,
retries: u32,
}
fn main() {
// Server sends v2 response to v1 client
let v2_response = r#"{
"timeout": 30,
"retries": 3,
"cache_ttl": 300
}"#;
// v1 client silently ignores unknown field
let config: ConfigV1 = serde_json::from_str(v2_response).unwrap();
println!("{:?}", config);
// ConfigV1 { timeout: 30, retries: 3 }
// cache_ttl is silently discarded
}Without the attribute, new fields are ignored, enabling forward compatibility.
Backward Compatibility
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct Config {
timeout: u32,
retries: u32,
}
fn main() {
// Old client sends v1 request to v2 server
let v1_request = r#"{
"timeout": 30,
"retries": 3
}"#;
// This works - no unknown fields
let config: Config = serde_json::from_str(v1_request).unwrap();
println!("{:?}", config);
}Old clients sending fewer fields always works, regardless of the attribute.
Silent Data Loss Risk
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct Settings {
debug: bool,
verbose: bool,
}
fn main() {
// Client typo: "debugg" instead of "debug"
let json_with_typo = r#"{
"debugg": true,
"verbose": true
}"#;
// Without deny_unknown_fields: silently accepts with defaults
let settings: Settings = serde_json::from_str(json_with_typo).unwrap();
println!("{:?}", settings);
// Settings { debug: false, verbose: true }
// debug is false (default) because "debugg" was ignored!
}Without deny_unknown_fields, typos result in silent data loss.
Catching Typos
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct Settings {
debug: bool,
verbose: bool,
}
fn main() {
let json_with_typo = r#"{
"debugg": true,
"verbose": true
}"#;
let result: Result<Settings, _> = serde_json::from_str(json_with_typo);
match result {
Ok(settings) => println!("{:?}", settings),
Err(e) => {
// Catches the typo immediately
println!("Configuration error: {}", e);
// unknown field `debugg`, expected `debug` or `verbose`
}
}
}deny_unknown_fields catches typos and configuration errors at parse time.
API Versioning Strategies
use serde::Deserialize;
// Strategy 1: Permissive (forward compatible)
#[derive(Deserialize, Debug)]
struct ApiV1Permissive {
name: String,
version: String,
// Unknown fields ignored - new fields work
}
// Strategy 2: Strict (catches errors, breaks on new fields)
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct ApiV1Strict {
name: String,
version: String,
// Unknown fields rejected - catches typos
}
// Strategy 3: Explicit unknown field handling
#[derive(Deserialize, Debug)]
struct ApiV1Explicit {
name: String,
version: String,
#[serde(flatten)]
extra: serde_json::Value, // Capture unknown fields
}
fn main() {
let json = r#"{"name": "my-api", "version": "1.0", "new_field": "value"}"#;
// Permissive: works
let p: ApiV1Permissive = serde_json::from_str(json).unwrap();
println!("Permissive: {:?}", p);
// Strict: fails
let s: Result<ApiV1Strict, _> = serde_json::from_str(json);
println!("Strict: {:?}", s);
// Explicit: captures unknown fields
let e: ApiV1Explicit = serde_json::from_str(json).unwrap();
println!("Explicit: {:?}", e);
// extra contains {"new_field": "value"}
}Three strategies with different trade-offs for API evolution.
Capturing Unknown Fields
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct Config {
name: String,
#[serde(flatten)]
other: serde_json::Map<String, serde_json::Value>,
}
fn main() {
let json = r#"{
"name": "my-service",
"timeout": 30,
"retries": 3,
"debug": true
}"#;
let config: Config = serde_json::from_str(json).unwrap();
println!("Name: {}", config.name);
println!("Other fields: {:?}", config.other);
// {"timeout": Number(30), "retries": Number(3), "debug": Bool(true)}
// Can process unknown fields
if let Some(timeout) = config.other.get("timeout") {
println!("Timeout: {:?}", timeout);
}
}Use #[serde(flatten)] to capture unknown fields instead of rejecting or ignoring.
Default Values with Unknown Fields
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct ServerConfig {
#[serde(default)]
port: u16,
#[serde(default = "default_host")]
host: String,
}
fn default_host() -> String {
"localhost".to_string()
}
fn main() {
// Both fields missing but no unknown fields
let json = r#"{}"#;
let config: ServerConfig = serde_json::from_str(json).unwrap();
println!("{:?}", config);
// ServerConfig { port: 0, host: "localhost" }
// Unknown field still rejected
let json_with_unknown = r#"{"hostname": "example.com"}"#;
let result: Result<ServerConfig, _> = serde_json::from_str(json_with_unknown);
assert!(result.is_err());
}Default values work independently of deny_unknown_fields.
Nested Structures
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct DatabaseConfig {
host: String,
port: u16,
}
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct AppConfig {
database: DatabaseConfig,
name: String,
}
fn main() {
let json = r#"{
"name": "my-app",
"database": {
"host": "localhost",
"port": 5432,
"ssl": true
}
}"#;
// Error: unknown field `ssl` in nested struct
let result: Result<AppConfig, _> = serde_json::from_str(json);
assert!(result.is_err());
}deny_unknown_fields applies recursively to nested structs that also have the attribute.
Mixed Strictness
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct StrictConfig {
name: String,
// Unknown fields rejected at this level
}
#[derive(Deserialize, Debug)]
struct PermissiveMetadata {
created_at: String,
// Unknown fields ignored at this level
}
#[derive(Deserialize, Debug)]
struct MixedConfig {
config: StrictConfig,
metadata: PermissiveMetadata,
}
fn main() {
let json = r#"{
"config": {
"name": "test",
"extra": "rejected"
},
"metadata": {
"created_at": "2024-01-01",
"extra": "ignored"
}
}"#;
// Config section is strict, metadata is permissive
let result: Result<MixedConfig, _> = serde_json::from_str(json);
assert!(result.is_err()); // "extra" in config is rejected
}Each struct controls its own unknown field handling independently.
Client-Side Considerations
use serde::Deserialize;
// API client for external service
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct ApiResponse {
id: String,
status: String,
}
// Problem: API adds new field, client breaks
fn handle_response(json: &str) -> Result<ApiResponse, serde_json::Error> {
serde_json::from_str(json)
}
fn main() {
// API adds "timestamp" field
let new_response = r#"{
"id": "123",
"status": "ok",
"timestamp": "2024-01-15"
}"#;
// Client with deny_unknown_fields fails
match handle_response(new_response) {
Ok(response) => println!("{:?}", response),
Err(e) => println!("API change detected: {}", e),
}
}For external APIs, avoid deny_unknown_fields to handle API evolution gracefully.
Server-Side Considerations
use serde::Deserialize;
// Server receiving client requests
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct CreateUserRequest {
username: String,
email: String,
}
fn handle_create_user(json: &str) -> Result<String, String> {
let request: CreateUserRequest = serde_json::from_str(json)
.map_err(|e| format!("Invalid request: {}", e))?;
// Process request
Ok(format!("Created user: {}", request.username))
}
fn main() {
// Client sends unexpected field
let invalid_request = r#"{
"username": "alice",
"email": "alice@example.com",
"role": "admin"
}"#;
match handle_create_user(invalid_request) {
Ok(msg) => println!("{}", msg),
Err(e) => println!("Error: {}", e),
// Error: Invalid request: unknown field `role`
}
}For server validation, deny_unknown_fields catches unexpected client behavior.
Error Messages
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct Point {
x: f64,
y: f64,
}
fn main() {
let json = r#"{"x": 1.0, "y": 2.0, "z": 3.0}"#;
match serde_json::from_str::<Point>(json) {
Ok(point) => println!("{:?}", point),
Err(e) => {
// Clear error message indicating the unknown field
println!("Error: {}", e);
// unknown field `z`, expected `x` or `y`
}
}
// Multiple unknown fields
let json = r#"{"x": 1.0, "y": 2.0, "a": 1, "b": 2}"#;
match serde_json::from_str::<Point>(json) {
Ok(point) => println!("{:?}", point),
Err(e) => {
println!("Error: {}", e);
// unknown field `a`, expected `x` or `y`
// Note: only first unknown field is reported
}
}
}Error messages clearly indicate the unknown field and expected fields.
Optional Fields vs Unknown Fields
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct Config {
required: String,
optional: Option<u32>,
}
fn main() {
// Missing optional field is fine
let json = r#"{"required": "test"}"#;
let config: Config = serde_json::from_str(json).unwrap();
println!("{:?}", config);
// Config { required: "test", optional: None }
// Unknown field is rejected
let json = r#"{"required": "test", "unknown": 123}"#;
let result: Result<Config, _> = serde_json::from_str(json);
assert!(result.is_err());
// Present optional field works
let json = r#"{"required": "test", "optional": 42}"#;
let config: Config = serde_json::from_str(json).unwrap();
println!("{:?}", config);
}Optional fields are different from unknown fields; deny_unknown_fields doesn't affect them.
Renamed Fields
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct User {
#[serde(rename = "userName")]
username: String,
#[serde(rename = "emailAddress")]
email: String,
}
fn main() {
// Correct: use renamed field names in JSON
let json = r#"{"userName": "alice", "emailAddress": "alice@example.com"}"#;
let user: User = serde_json::from_str(json).unwrap();
println!("{:?}", user);
// Error: using struct field names
let json = r#"{"username": "alice", "email": "alice@example.com"}"#;
let result: Result<User, _> = serde_json::from_str(json);
assert!(result.is_err());
// unknown field `username`, expected `userName` or `emailAddress`
// Error: unknown field
let json = r#"{"userName": "alice", "emailAddress": "a@b.com", "role": "admin"}"#;
let result: Result<User, _> = serde_json::from_str(json);
assert!(result.is_err());
}deny_unknown_fields uses the serialized (renamed) field names in error messages.
Aliased Fields
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct Config {
#[serde(alias = "timeout_ms")]
timeout: u64,
}
fn main() {
// Primary name works
let json = r#"{"timeout": 1000}"#;
let config: Config = serde_json::from_str(json).unwrap();
println!("{:?}", config);
// Alias works
let json = r#"{"timeout_ms": 1000}"#;
let config: Config = serde_json::from_str(json).unwrap();
println!("{:?}", config);
// Both provided: uses primary, alias ignored
let json = r#"{"timeout": 1000, "timeout_ms": 2000}"#;
let result: Result<Config, _> = serde_json::from_str(json);
assert!(result.is_err()); // timeout_ms is unknown when timeout present
}Aliases are recognized as valid fields, not unknown.
Comparison Table
| Aspect | With deny_unknown_fields |
Without |
|---|---|---|
| Typos | Caught immediately | Silently ignored |
| New fields | Breaks old clients | Ignored by old clients |
| API evolution | Requires version management | More flexible |
| Debugging | Clearer error messages | Silent failures |
| Forward compat | Breaks on new fields | Handles new fields |
| Backward compat | Works (fewer fields) | Works |
Synthesis
Use deny_unknown_fields when:
- Building internal APIs with strict contracts
- Configuration files where typos should be caught
- Security-sensitive parsing (reject unexpected input)
- Debugging complex deserialization issues
- APIs you fully control and version carefully
Avoid deny_unknown_fields when:
- Consuming external APIs that may evolve
- Building forward-compatible clients
- Supporting multiple API versions
- Needing flexibility for optional extensions
Alternatives:
- Use
#[serde(flatten)]withserde_json::Valueto capture unknown fields - Log warnings for unknown fields instead of rejecting
- Use API versioning with different struct types
Key insight: deny_unknown_fields is a trade-off between strict validation and flexibility. It prevents silent data loss from typos but breaks forward compatibility. For APIs you control and can version carefully, the strictness is valuable. For external APIs or APIs that need to evolve without breaking clients, the default permissive behavior is safer. Choose based on whether you need strict contracts or flexible evolution.
