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)] with serde_json::Value to 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.