How to Handle Optional Fields in Serde

Use the #[serde(default)] attribute on struct fields to automatically assign a default value when the field is missing during deserialization.

When input leaves gaps

You are building a CLI tool that reads a configuration file. The user sends a JSON payload: {"name": "my-app"}. Your Rust struct expects name and port. Serde tries to deserialize the JSON into your struct and fails. The error message says missing field "port". You don't want the program to crash because the user omitted a field that has a sensible fallback. You want port to become 8080 or 0 when the input leaves it out.

This friction happens constantly when moving from dynamic languages to Rust. JavaScript and Python let you access missing keys and get undefined or None that you handle later. Rust demands that your struct fields always hold a valid value. If the input is incomplete, Serde needs explicit instructions on how to fill the gaps.

The default safety net

The #[serde(default)] attribute tells Serde to use the Default trait for a field when that field is missing from the input. The field in your struct remains a concrete type, not an Option. The struct always contains a value. The input is the part that can be incomplete.

Think of a registration form. The "Username" field is required. The "Bio" field is optional. If the user leaves "Bio" blank, the system saves an empty string, not a null value. The database schema requires a string. The form just allows the user to skip it. #[serde(default)] is the empty string for your struct fields.

Here is the smallest case: a struct with one required field and one optional fallback.

use serde::{Deserialize, Serialize};

/// Configuration for a simple server.
#[derive(Debug, Deserialize, Serialize)]
struct Config {
    /// The application name is required.
    name: String,

    /// If port is missing, Serde uses u16::default(), which is 0.
    #[serde(default)]
    port: u16,
}

fn main() {
    let json = r#"{"name": "my-app"}"#;
    // JSON string omits the port key entirely.

    // Deserialization succeeds. port becomes 0.
    let config: Config = serde_json::from_str(json).unwrap();
    // unwrap() panics if JSON is malformed, but succeeds here.
    println!("{:?}", config);
}

The attribute #[serde(default)] works only when the field's type implements the Default trait. Primitive types like u16, bool, and String implement Default. u16 defaults to 0. bool defaults to false. String defaults to an empty string. Custom structs can implement Default to provide their own fallback values.

Treat missing keys as a signal, not a bug. Fill them intentionally.

How Serde fills the blanks

When Serde deserializes a struct, it iterates over the fields. For each field, it looks for a matching key in the input. If the key exists, Serde deserializes the value into the field. If the key is missing, Serde checks the field's attributes.

If the field has #[serde(default)], Serde calls Default::default() for that type and inserts the result. The struct is fully populated. Deserialization continues. If the field does not have #[serde(default)] and the key is missing, Serde returns a runtime error: missing field "field_name". The code compiles fine. The failure happens when you call from_str or from_slice.

The Default trait is part of the standard library. You can derive it for your own types.

use serde::{Deserialize, Serialize};

/// Default settings for a worker process.
#[derive(Debug, Default, Deserialize, Serialize)]
struct WorkerSettings {
    /// Number of threads. Defaults to 1.
    threads: usize,

    /// Log level. Defaults to "info".
    log_level: String,
}

/// Application config using the nested default.
#[derive(Debug, Deserialize, Serialize)]
struct AppConfig {
    app_name: String,

    /// If settings is missing, the whole struct defaults.
    #[serde(default)]
    settings: WorkerSettings,
}

fn main() {
    let json = r#"{"app_name": "worker"}"#;
    // JSON contains only the top-level key.

    let config: AppConfig = serde_json::from_str(json).unwrap();
    // WorkerSettings::default() runs, filling threads and log_level.

    // settings is WorkerSettings { threads: 0, log_level: "" }
    println!("{:?}", config.settings);
}

Deriving Default on WorkerSettings makes every field default to its own default. usize becomes 0. String becomes "". You can override specific fields in the Default implementation if you need non-zero defaults for the struct as a whole. The #[serde(default)] on settings in AppConfig delegates to WorkerSettings::default().

Convention aside: When you derive Default on a struct, the generated implementation uses the default for each field. If you need a custom default for the struct, implement Default manually. Do not use #[serde(default)] on the struct just to get a custom default. Use #[serde(default = "custom_func")] on the struct if you need a function. Keep the Default trait for the type's canonical zero-value.

Custom defaults and real-world configs

The Default trait works for simple cases. Real-world configurations often need specific values. You might want port to default to 8080 instead of 0. You might want retries to default to 3. The Default trait for u16 always returns 0. You cannot change that.

Use #[serde(default = "function")] to point to a function that returns the default value. The function must be in scope and return the field's type. It takes no arguments.

use serde::{Deserialize, Serialize};

/// Returns the default port for the server.
fn default_port() -> u16 {
    8080
}

/// Returns the default retry count.
fn default_retries() -> u32 {
    3
}

#[derive(Debug, Deserialize, Serialize)]
struct ServerConfig {
    host: String,

    /// Uses the function reference for the default.
    #[serde(default = "default_port")]
    port: u16,

    #[serde(default = "default_retries")]
    retries: u32,
}

fn main() {
    let json = r#"{"host": "localhost"}"#;
    // Input provides host but leaves port and retries out.

    let config: ServerConfig = serde_json::from_str(json).unwrap();
    // Serde calls default_port() and default_retries() automatically.

    println!("Port: {}, Retries: {}", config.port, config.retries);
}

The string in default = "..." is a path to a function. Serde resolves the path at compile time. The function is called only when the field is missing. This keeps the default logic close to the struct definition. You can also use constants if the type allows, but functions are more flexible.

Convention aside: Use #[serde(default)] when the type's Default implementation is the correct fallback. Use #[serde(default = "func")] when the default is specific to this field or the type's Default is not appropriate. This distinction helps other developers understand why a field has a particular value. A function name like default_port is self-documenting. A bare #[serde(default)] on a u16 signals "zero is fine".

The Option trap

Developers often confuse #[serde(default)] with Option<T>. They serve different purposes. Option<T> models the absence of a value in the type system. #[serde(default)] provides a fallback value when input is missing.

If you use Option<T> without #[serde(default)], a missing field deserializes to None. This is the standard behavior for optional data.

use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct ConfigWithOption {
    name: String,

    /// Missing field becomes None.
    port: Option<u16>,
}

fn main() {
    let json = r#"{"name": "app"}"#;
    // Option<T> naturally handles missing keys without extra attributes.

    let config: ConfigWithOption = serde_json::from_str(json).unwrap();
    // Deserialization succeeds. port is None.

    println!("{:?}", config.port);
}

If you add #[serde(default)] to an Option<T> field, the behavior changes. Serde calls Default::default() for Option<T>. The default for Option<T> is None. So #[serde(default)] on Option<T> does nothing different than omitting it. The field still becomes None.

The trap appears when you want a default value inside the Option. You might think #[serde(default)] will give you Some(default_value). It does not. Default::default() for Option is always None. To get Some(value) when the field is missing, you must use #[serde(default = "func")] where the function returns Some(value).

use serde::Deserialize;

fn default_port_opt() -> Option<u16> {
    Some(8080)
}

#[derive(Debug, Deserialize)]
struct ConfigWithOptionDefault {
    name: String,

    /// Missing field becomes Some(8080).
    #[serde(default = "default_port_opt")]
    port: Option<u16>,
}

fn main() {
    let json = r#"{"name": "app"}"#;
    // Input omits port, triggering the custom function.

    let config: ConfigWithOptionDefault = serde_json::from_str(json).unwrap();
    // default_port_opt() runs, returning Some(8080).

    println!("{:?}", config.port);
}

Adding default to an Option<T> field usually gives you Some(default), not None. If you want None for missing fields, use Option<T> without any default attribute. If you want a concrete value, use a non-Option type with default. Mixing Option and default is rarely what you want. It creates a state where the field is always Some, defeating the purpose of Option.

Check your types carefully. Option<T> with default is a logic bomb waiting to happen. You expect None for missing data, but you get Some(value). Your code that handles None never runs. Use Option only when you need to distinguish between "explicitly set" and "missing". Use default when "missing" means "use this value".

Serialization doesn't care about defaults

The #[serde(default)] attribute affects deserialization only. It tells Serde how to handle missing input. It does not affect serialization. When you serialize the struct back to JSON, Serde outputs all fields, including those that have default values.

If you have a Config with port: 0 and you serialize it, the JSON will contain "port": 0. The default attribute is invisible during serialization. This is intentional. Serialization should represent the current state of the struct. If the struct has a value, it gets serialized.

Often, you want to hide default values during serialization. You don't want to send "port": 0 if 0 means "use default". You want the JSON to omit the field. Use #[serde(skip_serializing_if = "predicate")] to conditionally skip fields.

use serde::{Deserialize, Serialize};

fn is_default_port(port: &u16) -> bool {
    *port == 0
}

#[derive(Debug, Deserialize, Serialize)]
struct Config {
    name: String,

    /// Deserialize with default 0. Serialize only if not 0.
    #[serde(default, skip_serializing_if = "is_default_port")]
    port: u16,
}

fn main() {
    let config = Config {
        name: "app".to_string(),
        port: 0,
    };

    let json = serde_json::to_string(&config).unwrap();
    // is_default_port returns true, so port is omitted.
    println!("{}", json);

    let config_with_port = Config {
        name: "app".to_string(),
        port: 8080,
    };

    let json2 = serde_json::to_string(&config_with_port).unwrap();
    // is_default_port returns false, so port is included.
    println!("{}", json2);
}

The skip_serializing_if attribute takes a function reference. The function receives a reference to the field value and returns bool. If the function returns true, Serde skips the field. This pairs well with default. You can deserialize with a default and serialize only when the value differs from the default.

Convention aside: For Option<T> fields, the standard library provides Option::is_none. You can use #[serde(skip_serializing_if = "Option::is_none")] to skip None values. This is the idiomatic way to handle optional fields in serialization. It keeps the JSON clean and reduces payload size.

Serialization and deserialization are separate concerns. default only fixes one side. If you want symmetric behavior, you need both default and skip_serializing_if. Treat them as a pair when designing your config structs.

Pitfalls and errors

If you forget the #[serde(default)] attribute on a field that is missing in the input, Serde returns a runtime error: missing field "field_name". The code compiles successfully. The failure occurs when you call the deserialization function.

If you use #[serde(default)] on a field whose type does not implement Default, the compiler rejects the code with E0277 (the trait Default is not implemented for MyType). You must implement Default for the type or use #[serde(default = "func")].

If you use #[serde(default = "func")] and the function is not in scope, the compiler reports cannot find function func in this scope. Ensure the function is visible where the struct is defined. You can use module paths like default = "crate::defaults::port".

Another pitfall is using #[serde(default)] on a struct with #[serde(deny_unknown_fields)]. These attributes work together. deny_unknown_fields rejects keys in the input that are not in the struct. default fills in missing fields. If the input has an unknown key, deserialization fails regardless of defaults. If the input is missing a key, default handles it. They address different problems.

Don't use default to mask validation errors. If a field is missing and you default it, you lose the information that the user didn't provide it. If you need to validate that a field was present, use Option<T> and check for None after deserialization. default is for convenience, not for validation.

Decision: defaults vs options

Use #[serde(default)] on a field when the type implements Default and that default is the correct fallback for missing input. Use #[serde(default = "function")] when the default value depends on logic or constants that don't fit the type's general Default implementation. Use Option<T> without any default attribute when you need to distinguish between "the user explicitly sent a value" and "the user sent nothing at all". Use #[serde(default)] on the struct definition when every field in the struct should fall back to its default if missing. Use #[serde(skip_serializing_if = "predicate")] alongside default when you want to omit default values from the serialized output.

Pick the tool that matches your semantics. Missing data isn't always the same as zero. If absence carries meaning, use Option. If absence means "use the standard fallback", use default.

Where to go next