How to handle optional fields with serde

Handle optional fields in serde by using the #[serde(default)] attribute to assign default values for missing data.

When the input leaves gaps

You are loading a configuration file for your CLI tool. The user provides a JSON file with {"host": "localhost"}. Your Rust struct expects host and port. The host key exists. The port key does not. Without help, serde returns a deserialization error because your struct requires every field. The application crashes before it starts.

You need a way to tell serde how to handle missing data. Rust offers two distinct strategies. You can wrap the field in Option<T> to make the absence explicit in the type system. Or you can use #[serde(default)] to provide a fallback value while keeping the field type unchanged. The choice changes how the rest of your code interacts with the data.

The two paths: Option versus default

Option<T> and #[serde(default)] solve the same immediate problem: missing keys in the input. They solve it differently.

Option<T> changes the type of the field. If the key is missing, the field becomes None. Your code must handle the None case every time it uses the value. This is the right choice when the absence of data is a meaningful state. The caller needs to know whether the user provided a value or whether the system is using a placeholder.

#[serde(default)] keeps the type unchanged. If the key is missing, serde calls the Default implementation for the type and fills the field. Your code sees a valid value and never knows the key was missing. This is the right choice when the field is logically required for the application to work, but the input format allows omitting it. You want the ergonomics of the inner type without the boilerplate of unwrapping.

Use Option when you need to distinguish "user provided X" from "user provided nothing". Use default when you just need a value and the fallback is obvious.

Minimal example: #[serde(default)]

The #[serde(default)] attribute tells serde to invoke Default::default() when a field is missing during deserialization. This works for any type that implements the Default trait.

use serde::Deserialize;

#[derive(Deserialize, Debug)]
struct Config {
    // Required field. Deserialization fails if this key is missing.
    name: String,
    
    // Optional in input, but always a u16 in code.
    // serde calls u16::default() which returns 0.
    #[serde(default)]
    port: u16,
}

fn main() {
    let json = r#"{"name": "my_app"}"#;
    
    // Deserialization succeeds. port is filled with 0.
    let config: Config = serde_json::from_str(json).unwrap();
    println!("{config:?}");
}

The port field is a plain u16. You can perform arithmetic on it, pass it to functions expecting u16, and store it without wrapping. The #[serde(default)] attribute handles the gap between the sparse input and the strict struct.

Convention aside: #[serde(default)] is the standard way to handle optional config fields. It keeps the domain model clean. You avoid Option boilerplate in business logic where the value is effectively required.

What happens under the hood

When serde deserializes a struct, it iterates over the fields. For each field, it looks for the corresponding key in the input.

If the key is present, serde deserializes the value into the field type. If the key is missing, serde checks for attributes. If it finds #[serde(default)], it calls Default::default() for the field type. The result is assigned to the field. Deserialization continues.

If the key is missing and there is no default attribute, serde records an error. After checking all fields, serde returns the error to the caller. The struct is not constructed.

The Default trait is part of the Rust standard library. Primitive types like u16, bool, and String implement it. u16::default() returns 0. bool::default() returns false. String::default() returns an empty string. Collections like Vec and HashMap return empty instances.

If you use a custom type, that type must implement Default. Otherwise, the compiler rejects the code.

Realistic example: mixing strategies

Real applications often need a mix of strategies. Some fields are required. Some have sensible defaults. Some are truly optional. Some need custom default values that differ from the type's Default implementation.

use serde::Deserialize;

// Custom default function for port.
// Must take zero arguments and return the target type.
fn default_port() -> u16 {
    8080
}

#[derive(Deserialize, Debug)]
struct AppConfig {
    // Required. No default. Deserialization fails if missing.
    name: String,
    
    // Custom default. Uses 8080 instead of 0.
    #[serde(default = "default_port")]
    port: u16,
    
    // Standard default. bool::default() is false.
    #[serde(default)]
    debug: bool,
    
    // Standard default. Vec::default() is an empty vector.
    #[serde(default)]
    tags: Vec<String>,
    
    // Truly optional. Code must handle None.
    // Option handles missing keys automatically without #[serde(default)].
    alias: Option<String>,
}

fn main() {
    let json = r#"{"name": "web_server", "tags": ["http", "api"]}"#;
    
    let config: AppConfig = serde_json::from_str(json).unwrap();
    println!("{config:?}");
}

The port field uses #[serde(default = "default_port")]. The string inside the attribute is the name of a function. Serde calls this function when the key is missing. The function must have the signature fn() -> T where T matches the field type. It cannot take arguments.

The alias field uses Option<String>. Option types handle missing keys automatically. Serde sets the field to None if the key is absent. You do not need #[serde(default)] on an Option field. Adding it is redundant because Option::default() is None, which is exactly what serde does anyway.

Convention aside: Name default functions clearly. default_port is better than port_fn. Keep them private or pub(crate) unless you need to expose them. The function signature is rigid. If you need to compute a default based on other fields, default is the wrong tool. Use a post-deserialization validation step instead.

Pitfalls and compiler errors

Using default attributes introduces a few common traps.

The compiler rejects #[serde(default)] if the field type does not implement Default. You get E0277 (the trait bound MyType: Default is not satisfied). This happens when you use a custom struct or enum without deriving or implementing Default. Fix it by adding #[derive(Default)] to the type or implementing the trait manually.

The default = "func" attribute requires a function with zero arguments. If you write #[serde(default = "my_func")] and my_func takes parameters, the compiler complains about mismatched types. Serde cannot pass context to the default function. The function must be self-contained.

#[serde(default)] only triggers when the key is missing. It does not rescue invalid data. If the JSON contains "port": "not_a_number", serde attempts to deserialize the string into u16. That fails. The default attribute is ignored. The deserialization error propagates. default is a fallback for absence, not a validator for content.

Serialization is the mirror of deserialization. #[serde(default)] affects deserialization only. When you serialize the struct, serde includes the field regardless of its value. If you want to hide fields during serialization, you need #[serde(skip_serializing_if = "...")]. For Option fields, the standard pattern is #[serde(skip_serializing_if = "Option::is_none")]. This keeps the output clean by omitting None values.

Keep the type clean. Use default to avoid Option boilerplate when the fallback is obvious. Use skip_serializing_if to keep serialization output minimal.

Decision matrix

Use Option<T> when the absence of data is a meaningful state and the caller must distinguish between a provided value and a missing one.

Use #[serde(default)] when the field is logically required for the application but the input format allows omitting it, and you want to preserve the ergonomics of the inner type.

Use #[serde(default = "func")] when the fallback value is not the type's Default implementation and you need a specific constant or computed value.

Use #[serde(skip_serializing_if = "Option::is_none")] when you have an Option field and want to exclude it from serialized output to reduce payload size or match API conventions.

Use #[serde(default)] on the struct level when every field in the struct should default to its Default value if missing, which is common for partial update payloads in REST APIs.

Trust the Default trait. It is the standard contract for fallback values. If a type has a sensible zero or empty state, derive Default and use #[serde(default)]. If the fallback requires logic, write a zero-arg function. If the fallback requires context, handle it after deserialization.

Where to go next