How does serde::ser::SerializeStruct::skip_field omit fields conditionally during serialization?

serde::ser::SerializeStruct::skip_field allows serializers to conditionally omit fields from the serialized output by not serializing a field value when certain conditions are met, enabling compact serialization where absent or default values are excluded. This method is called instead of serialize_field when a field should not appear in the output, and different serialization formats handle skipped fields differently.

Basic Conditional Field Omission

use serde::ser::{SerializeStruct, Serializer};
use serde::Serialize;
 
struct User {
    id: u32,
    name: String,
    email: Option<String>,
}
 
impl Serialize for User {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        // Count only non-skipped fields:
        let field_count = 2 + if self.email.is_some() { 1 } else { 0 };
        
        let mut s = serializer.serialize_struct("User", field_count)?;
        s.serialize_field("id", &self.id)?;
        s.serialize_field("name", &self.name)?;
        
        // Conditionally serialize or skip:
        if let Some(ref email) = self.email {
            s.serialize_field("email", email)?;
        } else {
            // Don't call skip_field in typical implementations
            // Just don't serialize the field at all
        }
        
        s.end()
    }
}
 
fn example() {
    let user = User {
        id: 1,
        name: "Alice".to_string(),
        email: Some("alice@example.com".to_string()),
    };
    let json = serde_json::to_string(&user).unwrap();
    // {"id":1,"name":"Alice","email":"alice@example.com"}
    
    let user_no_email = User {
        id: 2,
        name: "Bob".to_string(),
        email: None,
    };
    let json = serde_json::to_string(&user_no_email).unwrap();
    // {"id":2,"name":"Bob"}
    // email field omitted entirely
}

The common pattern is to not call serialize_field for skipped fields rather than calling skip_field.

The skip_field Method

use serde::ser::Serializer;
 
// SerializeStruct trait method:
pub trait SerializeStruct {
    type Ok;
    type Error: Error;
    
    // Serialize a field:
    fn serialize_field<T: ?Sized + Serialize>(
        &mut self,
        key: &'static str,
        value: &T,
    ) -> Result<(), Self::Error>;
    
    // Skip a field (advanced usage):
    fn skip_field(&mut self, key: &'static str) -> Result<(), Self::Error> {
        // Default implementation does nothing
        Ok(())
    }
    
    fn end(self) -> Result<Self::Ok, Self::Error>;
}

skip_field is optionally called to indicate a field was intentionally skipped.

Using skip_field vs Not Serializing

use serde::ser::{SerializeStruct, Serializer};
use serde::Serialize;
 
struct Data {
    id: u32,
    value: Option<i32>,
}
 
impl Serialize for Data {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let mut s = serializer.serialize_struct("Data", 2)?;
        s.serialize_field("id", &self.id)?;
        
        // Option 1: Don't call anything (typical approach):
        if let Some(ref value) = self.value {
            s.serialize_field("value", value)?;
        }
        // No field emitted when None
        
        s.end()
    }
}
 
impl Serialize for Data {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let mut s = serializer.serialize_struct("Data", 2)?;
        s.serialize_field("id", &self.id)?;
        
        // Option 2: Explicitly skip (rarely needed):
        match &self.value {
            Some(value) => s.serialize_field("value", value)?,
            None => s.skip_field("value")?,
        }
        
        s.end()
    }
}

Both approaches result in the field being omitted; skip_field makes the omission explicit.

serde(skip_serializing_if) Attribute

use serde::Serialize;
 
#[derive(Serialize)]
struct Config {
    name: String,
    
    #[serde(skip_serializing_if = "Option::is_none")]
    description: Option<String>,
    
    #[serde(skip_serializing_if = "Vec::is_empty")]
    tags: Vec<String>,
    
    #[serde(skip_serializing_if = "is_default")]
    count: u32,
}
 
fn is_default<T: Default + PartialEq>(value: &T) -> bool {
    value == &T::default()
}
 
fn attribute_example() {
    let config = Config {
        name: "app".to_string(),
        description: None,          // Skipped
        tags: vec![],               // Skipped
        count: 0,                   // Skipped (default)
    };
    
    let json = serde_json::to_string(&config).unwrap();
    // {"name":"app"}
    // Only name appears
}

#[serde(skip_serializing_if)] generates code that conditionally skips fields.

How skip_serializing_if Generates Code

use serde::ser::{SerializeStruct, Serializer};
use serde::Serialize;
 
// #[derive(Serialize)] generates code like:
struct Config {
    name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    description: Option<String>,
}
 
impl Serialize for Config {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        // Count non-skipped fields:
        let field_count = 1 + self.description.as_ref().map_or(0, |_| 1);
        
        let mut s = serializer.serialize_struct("Config", field_count)?;
        s.serialize_field("name", &self.name)?;
        
        // Generated conditional:
        if !Option::is_none(&self.description) {
            s.serialize_field("description", &self.description)?;
        }
        // skip_field is NOT called - field simply not serialized
        
        s.end()
    }
}

The derived implementation counts only non-skipped fields and omits skipped ones.

Custom Skip Logic with Serialize Implementation

use serde::ser::{SerializeStruct, Serializer};
use serde::Serialize;
 
struct Product {
    id: u64,
    name: String,
    price: f64,
    discount: Option<f64>,
    stock: u32,
}
 
impl Serialize for Product {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        // Count fields that will actually be serialized:
        let mut field_count = 3; // id, name, price always present
        
        if self.discount.is_some() {
            field_count += 1;
        }
        if self.stock > 0 {
            field_count += 1;
        }
        
        let mut s = serializer.serialize_struct("Product", field_count)?;
        s.serialize_field("id", &self.id)?;
        s.serialize_field("name", &self.name)?;
        s.serialize_field("price", &self.price)?;
        
        // Skip discount if None:
        if let Some(ref discount) = self.discount {
            s.serialize_field("discount", discount)?;
        }
        
        // Skip stock if zero:
        if self.stock > 0 {
            s.serialize_field("stock", &self.stock)?;
        }
        
        s.end()
    }
}

Manual implementations can use arbitrary conditions for skipping.

Field Counting for Efficiency

use serde::ser::{SerializeStruct, Serializer};
use serde::Serialize;
 
struct Event {
    timestamp: u64,
    message: String,
    level: Option<String>,
    metadata: Option<std::collections::HashMap<String, String>>,
}
 
impl Serialize for Event {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        // Accurate field count is important for some serializers:
        let mut count = 2; // timestamp, message always present
        
        if self.level.is_some() {
            count += 1;
        }
        if self.metadata.as_ref().map_or(false, |m| !m.is_empty()) {
            count += 1;
        }
        
        let mut s = serializer.serialize_struct("Event", count)?;
        s.serialize_field("timestamp", &self.timestamp)?;
        s.serialize_field("message", &self.message)?;
        
        if let Some(ref level) = self.level {
            s.serialize_field("level", level)?;
        }
        
        if let Some(ref metadata) = self.metadata {
            if !metadata.is_empty() {
                s.serialize_field("metadata", metadata)?;
            }
        }
        
        s.end()
    }
}

Accurate field counts help serializers pre-allocate space.

Skip vs Null

use serde::Serialize;
 
#[derive(Serialize)]
struct Data {
    name: String,
    
    // Skipped when None:
    #[serde(skip_serializing_if = "Option::is_none")]
    optional_skip: Option<String>,
    
    // Serialized as null when None:
    optional_null: Option<String>,
}
 
fn skip_vs_null() {
    let data = Data {
        name: "test".to_string(),
        optional_skip: None,
        optional_null: None,
    };
    
    let json = serde_json::to_string(&data).unwrap();
    // {"name":"test","optional_null":null}
    // optional_skip is absent
    // optional_null is present as null
}

Skipping omits the field entirely; serializing None outputs null.

Format-Specific Behavior

use serde::Serialize;
 
#[derive(Serialize)]
struct Record {
    id: u32,
    #[serde(skip_serializing_if = "Option::is_none")]
    value: Option<String>,
}
 
fn format_behavior() {
    let record = Record { id: 1, value: None };
    
    // JSON: field absent
    let json = serde_json::to_string(&record).unwrap();
    // {"id":1}
    
    // Some binary formats might encode:
    // - Absent field explicitly
    // - Or use field presence/absence as optimization
    
    // skip_field allows formats to handle absence:
    // - JSON: omit field
    // - Some formats: record skip explicitly
}

Different formats handle skipped fields differently internally.

Custom Serializer Handling of skip_field

use serde::ser::{self, Serialize, SerializeStruct, Serializer};
use std::collections::HashMap;
 
// Custom serializer that tracks skipped fields:
struct TrackingSerializer;
 
impl Serializer for TrackingSerializer {
    type Ok = String;
    type Error = ser::Error;
    type SerializeStruct = TrackingStruct;
    // ... other associated types ...
    
    fn serialize_struct(
        self,
        _name: &'static str,
        _len: usize,
    ) -> Result<Self::SerializeStruct, Self::Error> {
        Ok(TrackingStruct {
            fields: HashMap::new(),
            skipped: Vec::new(),
        })
    }
    
    // ... other methods ...
}
 
struct TrackingStruct {
    fields: HashMap<&'static str, String>,
    skipped: Vec<&'static str>,
}
 
impl SerializeStruct for TrackingStruct {
    type Ok = String;
    type Error = ser::Error;
    
    fn serialize_field<T: ?Sized + Serialize>(
        &mut self,
        key: &'static str,
        value: &T,
    ) -> Result<(), Self::Error> {
        // Serialize and store field:
        self.fields.insert(key, format!("{:?}", value));
        Ok(())
    }
    
    fn skip_field(&mut self, key: &'static str) -> Result<(), Self::Error> {
        // Record that this field was skipped:
        self.skipped.push(key);
        Ok(())
    }
    
    fn end(self) -> Result<Self::Ok, Self::Error> {
        // Output could include skipped field info:
        let fields: Vec<_> = self.fields.iter()
            .map(|(k, v)| format!("{}:{}", k, v))
            .collect();
        Ok(format!("{{{}}}", fields.join(",")))
    }
}

Custom serializers can use skip_field for format-specific handling.

skip_field Default Implementation

use serde::ser::SerializeStruct;
 
// Default implementation in serde:
pub trait SerializeStruct {
    fn skip_field(&mut self, _key: &'static str) -> Result<(), Self::Error> {
        // Default: do nothing
        // Most serializers don't need to track skipped fields
        Ok(())
    }
}
 
// This means:
// 1. Calling skip_field is usually a no-op
// 2. It exists for format-specific needs
// 3. Most implementations just don't serialize the field

The default implementation does nothing, making it optional.

When skip_field Matters

use serde::ser::{SerializeStruct, Serializer};
use serde::Serialize;
 
// Some formats encode field presence explicitly:
// - Protocol buffers: field presence is tracked
// - Some binary formats: skip vs null has meaning
 
// For JSON, skip_field typically does nothing:
// Just don't call serialize_field and field is absent
 
// For formats tracking field presence:
// skip_field might encode: "field X was present but skipped"
 
// Example scenario:
struct AuditLog {
    user: String,
    action: String,
    // Some fields tracked for audit purposes:
    #[serde(skip_serializing_if = "Option::is_none")]
    reason: Option<String>,
}
// Audit log might care about which fields were explicitly skipped

skip_field is primarily useful for formats that distinguish absence.

Combining Multiple Skip Conditions

use serde::Serialize;
 
#[derive(Serialize)]
struct Request {
    method: String,
    path: String,
    
    #[serde(skip_serializing_if = "Option::is_none")]
    query: Option<String>,
    
    #[serde(skip_serializing_if = "Option::is_none")]
    body: Option<String>,
    
    #[serde(skip_serializing_if = "HashMap::is_empty")]
    headers: std::collections::HashMap<String, String>,
    
    #[serde(skip_serializing_if = "is_default_timeout")]
    timeout_ms: u32,
}
 
fn is_default_timeout(timeout: &u32) -> bool {
    *timeout == 30_000  // Default timeout
}
 
fn combined_example() {
    let request = Request {
        method: "GET".to_string(),
        path: "/api/users".to_string(),
        query: Some("page=1".to_string()),
        body: None,
        headers: std::collections::HashMap::new(),
        timeout_ms: 30_000,
    };
    
    let json = serde_json::to_string(&request).unwrap();
    // {"method":"GET","path":"/api/users","query":"page=1"}
    // body, headers, timeout_ms all skipped
}

Multiple skip conditions can be combined for compact serialization.

Manual Implementation with skip_field

use serde::ser::{SerializeStruct, Serializer};
use serde::Serialize;
 
enum MaybeKnown<T> {
    Known(T),
    Unknown,
    NotApplicable,
}
 
struct Value {
    id: u32,
    data: MaybeKnown<f64>,
}
 
impl Serialize for MaybeKnown<T>
where
    T: Serialize,
{
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        match self {
            MaybeKnown::Known(v) => serializer.serialize_some(v),
            MaybeKnown::Unknown => serializer.serialize_none(),
            MaybeKnown::NotApplicable => {
                // For NotApplicable, skip field entirely:
                // This requires SerializeStruct context, not Serializer
                // So we'd need a different approach
                
                // In practice, use a wrapper or custom logic
                serializer.serialize_none()
            }
        }
    }
}
 
impl Serialize for Value {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let mut s = serializer.serialize_struct("Value", 1)?;
        s.serialize_field("id", &self.id)?;
        
        // Custom handling:
        match &self.data {
            MaybeKnown::Known(v) => {
                s.serialize_field("data", v)?;
            }
            MaybeKnown::Unknown => {
                // Serialize as null:
                s.serialize_field("data", &serde_json::json!(null))?;
            }
            MaybeKnown::NotApplicable => {
                // Skip field entirely:
                s.skip_field("data")?;
            }
        }
        
        s.end()
    }
}

skip_field enables tri-state logic: present, null, or absent.

Real-World Example: API Response

use serde::Serialize;
use std::collections::HashMap;
 
#[derive(Serialize)]
struct ApiResponse<T> {
    success: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    data: Option<T>,
    #[serde(skip_serializing_if = "Option::is_none")]
    error: Option<ApiError>,
    #[serde(skip_serializing_if = "HashMap::is_empty")]
    metadata: HashMap<String, String>,
}
 
#[derive(Serialize)]
struct ApiError {
    code: u32,
    message: String,
}
 
impl<T> ApiResponse<T> {
    fn success(data: T) -> Self {
        ApiResponse {
            success: true,
            data: Some(data),
            error: None,
            metadata: HashMap::new(),
        }
    }
    
    fn error(code: u32, message: String) -> Self {
        ApiResponse {
            success: false,
            data: None,
            error: Some(ApiError { code, message }),
            metadata: HashMap::new(),
        }
    }
}
 
fn api_example() {
    // Success response:
    let success: ApiResponse<String> = ApiResponse::success("result".to_string());
    let json = serde_json::to_string(&success).unwrap();
    // {"success":true,"data":"result"}
    
    // Error response:
    let error: ApiResponse<String> = ApiResponse::error(404, "Not found".to_string());
    let json = serde_json::to_string(&error).unwrap();
    // {"success":false,"error":{"code":404,"message":"Not found"}}
}

API responses often conditionally include fields based on success/error state.

Real-World Example: Patch Operations

use serde::Serialize;
 
// Patch operations: only included fields should be updated
#[derive(Serialize)]
struct UserPatch {
    #[serde(skip_serializing_if = "Option::is_none")]
    name: Option<String>,
    
    #[serde(skip_serializing_if = "Option::is_none")]
    email: Option<String>,
    
    #[serde(skip_serializing_if = "Option::is_none")]
    active: Option<bool>,
}
 
fn patch_example() {
    // Only updating name:
    let patch = UserPatch {
        name: Some("New Name".to_string()),
        email: None,
        active: None,
    };
    
    let json = serde_json::to_string(&patch).unwrap();
    // {"name":"New Name"}
    // Absent fields mean "don't change"
    
    // Server can distinguish:
    // - Field present: update to new value
    // - Field absent: don't change
    // - Field null: set to null
}

Patch operations use field absence to indicate "no change".

Deserialize Considerations

use serde::{Serialize, Deserialize};
 
#[derive(Serialize, Deserialize)]
struct Config {
    name: String,
    
    // Skip serializing if None, but can be deserialized:
    #[serde(skip_serializing_if = "Option::is_none")]
    version: Option<String>,
    
    // Default value when missing:
    #[serde(default)]
    enabled: bool,
}
 
fn roundtrip() {
    // Serializing:
    let config = Config {
        name: "app".to_string(),
        version: None,
        enabled: false,
    };
    let json = serde_json::to_string(&config).unwrap();
    // {"name":"app","enabled":false}
    
    // Deserializing:
    let parsed: Config = serde_json::from_str(&json).unwrap();
    // version defaults to None
    // enabled defaults to false (or uses value if present)
}

Skipping fields during serialization requires defaults for deserialization.

Key Points

fn key_points() {
    // 1. skip_field indicates intentional field omission
    // 2. Most implementations don't call serialize_field instead
    // 3. skip_serializing_if attribute generates conditional code
    // 4. Field count should match non-skipped fields
    // 5. Skipping omits field; serializing None produces null
    // 6. Default skip_field implementation does nothing
    // 7. Custom serializers can track skipped fields
    // 8. Useful for tri-state: present/absent/null
    // 9. Patch operations use absence to mean "no change"
    // 10. Works with any condition function
    // 11. Multiple fields can have different skip conditions
    // 12. Combine with default for round-trip serialization
    // 13. Accurate field count improves serializer efficiency
    // 14. JSON: skipped fields absent, not null
    // 15. Binary formats might encode skip differently
}

Key insight: skip_field provides a mechanism to explicitly indicate that a field should be omitted from serialization, though in practice most implementations achieve this by simply not calling serialize_field. The real power comes from combining #[serde(skip_serializing_if)] with conditions that determine whether a field should appear in the output—enabling compact serialization where default values, empty collections, or None values don't waste space in the output. This is particularly valuable for API responses and patch operations where the presence or absence of a field carries semantic meaning: an absent field means "don't modify" or "use default," while a present field (even if null) means "set explicitly." The distinction between skipping a field (absent from output) and serializing None (produces null in JSON) is crucial for protocols that differentiate between "not provided" and "explicitly null."