Loading pageâŚ
Rust walkthroughs
Loading pageâŚ
#[serde(skip_serializing_if = "Option::is_none")] attribute differ from using #[serde(default)] for optional fields?Serde's attribute system provides multiple mechanisms for handling optional fields, but skip_serializing_if and default serve fundamentally different purposes in the serialization pipeline. Understanding this distinction prevents subtle bugs where fields unexpectedly appear in output or deserialization fails on missing data.
The skip_serializing_if attribute controls serialization outputâit determines whether a field appears in the serialized representation. The default attribute controls deserialization behaviorâit specifies what value to use when a field is absent from the input data. These are orthogonal concerns that happen to intersect around Option types.
skip_serializing_ifThe skip_serializing_if attribute accepts a path to a function that returns a boolean. When the function returns true, serde omits the field entirely from the serialized output.
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct Config {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
timeout_ms: Option<u64>,
}
fn main() {
let config = Config {
name: "worker".to_string(),
timeout_ms: None,
};
let json = serde_json::to_string(&config).unwrap();
println!("{}", json);
// Output: {"name":"worker"}
// Note: timeout_ms is completely absent
}When timeout_ms is None, the field disappears from the JSON entirely. This produces cleaner output and avoids the explicit null that would otherwise appear. The function Option::is_none is a built-in method that returns true when the option contains no value.
When the field contains a value, it serializes normally:
let config = Config {
name: "worker".to_string(),
timeout_ms: Some(5000),
};
let json = serde_json::to_string(&config).unwrap();
println!("{}", json);
// Output: {"name":"worker","timeout_ms":5000}defaultThe default attribute specifies what value to use when a field is missing during deserialization. Without this attribute, serde requires all fields to be present in the input.
#[derive(Serialize, Deserialize, Debug)]
struct Config {
name: String,
#[serde(default)]
timeout_ms: Option<u64>,
}
fn main() {
let json = r#"{"name":"worker"}"#;
let config: Config = serde_json::from_str(json).unwrap();
println!("{:?}", config);
// Output: Config { name: "worker", timeout_ms: None }
}The #[serde(default)] attribute uses the type's Default trait implementation. For Option<T>, the default is None. This allows deserialization to succeed even when timeout_ms is absent from the JSON.
The most common pattern combines both attributes on the same field to ensure bidirectional consistency:
#[derive(Serialize, Deserialize, Debug)]
struct Config {
name: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
timeout_ms: Option<u64>,
}This combination means: don't serialize None values, and treat missing fields as None during deserialization. A value can make a round trip through serialization and deserialization without data loss or format changes.
fn round_trip_example() {
let original = Config {
name: "worker".to_string(),
timeout_ms: None,
};
let json = serde_json::to_string(&original).unwrap();
// {"name":"worker"}
let recovered: Config = serde_json::from_str(&json).unwrap();
// Config { name: "worker", timeout_ms: None }
assert_eq!(original.name, recovered.name);
assert_eq!(original.timeout_ms, recovered.timeout_ms);
}Consider what happens when you use only one of these attributes:
#[derive(Serialize, Deserialize, Debug)]
struct OnlySkip {
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<i32>,
}
fn serialization_without_deserialization_default() {
let original = OnlySkip { value: None };
let json = serde_json::to_string(&original).unwrap();
// {}
// This will panic! The field is missing, and there's no default.
let result: Result<OnlySkip, _> = serde_json::from_str(&json);
assert!(result.is_err());
}Serialization succeeds and produces {}, but deserialization fails because serde expects the value field to be present. The error message indicates a missing field.
Conversely, using only default produces a different asymmetry:
#[derive(Serialize, Deserialize, Debug)]
struct OnlyDefault {
#[serde(default)]
value: Option<i32>,
}
fn deserialization_without_serialization_skip() {
let original = OnlyDefault { value: None };
let json = serde_json::to_string(&original).unwrap();
// {"value":null}
// Deserialization works fine
let recovered: OnlyDefault = serde_json::from_str(&json).unwrap();
assert_eq!(recovered.value, None);
}This round-trips correctly, but the JSON contains "value":null instead of omitting the field entirely. For APIs that distinguish between "field not present" and "field is null" (rare but possible), this matters.
The default attribute can specify a custom function instead of using the type's Default implementation:
#[derive(Serialize, Deserialize, Debug)]
struct ServerConfig {
host: String,
#[serde(default = "default_port")]
port: u16,
}
fn default_port() -> u16 {
8080
}
fn custom_default_example() {
let json = r#"{"host":"localhost"}"#;
let config: ServerConfig = serde_json::from_str(json).unwrap();
println!("{:?}", config);
// Output: ServerConfig { host: "localhost", port: 8080 }
}Here port is not an Optionâit's a concrete u16. The default attribute provides the value when the field is missing. You cannot use skip_serializing_if here because there's no Option to check, and the field must have a value.
Applying default at the struct level provides defaults for all fields:
#[derive(Serialize, Deserialize, Debug, Default)]
#[serde(default)]
struct DatabaseConfig {
host: String,
port: u16,
max_connections: usize,
timeout_seconds: u64,
}
fn struct_level_default() {
let json = r#"{"host":"db.example.com"}"#;
let config: DatabaseConfig = serde_json::from_str(json).unwrap();
// All other fields use their Default trait values
// String: "", u16: 0, usize: 0, u64: 0
}This requires the struct to implement Default, which can be derived when all fields implement Default.
#[serde(skip)]A third attribute, skip, completely ignores a field for both serialization and deserialization:
#[derive(Serialize, Deserialize, Debug)]
struct CachedData {
key: String,
#[serde(skip)]
cached_computation: Option<String>,
}The cached_computation field never appears in serialized output and is never read from input. During deserialization, you must provide a defaultâeither through Default trait or by using #[serde(skip, default = "...")]. This differs from skip_serializing_if, which conditionally includes the field.
When designing types for serialization, consider your consumers and data sources:
For APIs you control: Combine skip_serializing_if = "Option::is_none" with default on all optional fields. This produces minimal JSON and handles missing fields gracefully.
For external APIs: Match their behavior precisely. Some APIs send null for absent valuesâyour types should expect this. Some APIs omit fieldsâuse default. Some APIs require explicit null to clear valuesâavoid skip_serializing_if.
For configuration files: Use default liberally so users can omit optional settings. The combination pattern works well here.
#[derive(Serialize, Deserialize, Debug)]
struct AppConfig {
name: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
log_level: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
database_url: Option<String>,
#[serde(default = "default_workers")]
workers: usize,
}
fn default_workers() -> usize {
num_cpus::get()
}The skip_serializing_if and default attributes operate on opposite ends of the serialization pipeline. skip_serializing_if is an output filter that removes fields from serialized data when they meet a condition. default is an input fallback that provides values when fields are absent from deserialized data.
For Option<T> fields, the canonical pattern combines both: #[serde(skip_serializing_if = "Option::is_none", default)]. This ensures None values don't pollute output with explicit nulls, and missing fields deserialize to None naturally. The attributes work together to create a coherent mental model where "absent in JSON" and "None in Rust" are equivalent states.
Understanding this distinction matters most when debugging serialization issues. If a field unexpectedly appears as null in output, check whether skip_serializing_if is configured. If deserialization fails with missing field errors, verify that default is present. The two attributes are independent tools that address independent problems.