How to Skip Fields During Serialization with Serde

Skip specific struct fields during serialization in Rust by adding the #[serde(skip_serializing)] attribute to the field definition.

How to Skip Fields During Serialization with Serde

You are building an API endpoint that returns user profiles. Your database model has a password_hash field. You serialize the user to JSON to send to the browser. Suddenly, the password hash is in the response. That is not just a bug. That is a security incident waiting to happen. You need the struct to hold the data for internal logic, but you need the serialization to leave it behind.

Or picture a game server sending state to a client. The server struct has a debug_timestamp and a connection_id used for internal tracking. The client doesn't need those fields. Sending them wastes bandwidth and clutters the payload. You want the struct to be a complete model for your Rust code, but you want the JSON output to be a curated subset.

Serde gives you precise control over what leaves your struct. The #[serde(skip_serializing)] attribute tells the serializer to ignore a field entirely. The field stays in the struct. It stays in memory. It just never makes it into the output.

The suitcase analogy

Think of serialization as packing a suitcase. You have a bag full of clothes, toiletries, a laptop, and a heavy textbook. When you are going to the gym, you do not pack the laptop. You leave it at home. The laptop still exists in your room. You can use it later. It is just not in the suitcase.

#[serde(skip_serializing)] is the rule that tells the packer: "This item stays in the room." The item remains part of your belongings, but it is excluded from the travel bag.

This distinction matters. Skipping serialization does not drop the value. It does not clear the field. The value is still accessible in your Rust code. If you print the struct, the field appears. If you pass the struct to a function, the field is there. Serialization is the only operation that ignores it.

Minimal example

Here is the basic pattern. You place the attribute on the field you want to hide.

use serde::Serialize;

/// A user record that holds sensitive data internally.
#[derive(Serialize)]
struct User {
    /// The public username goes into the JSON.
    username: String,

    /// The password stays in the struct but vanishes from JSON.
    #[serde(skip_serializing)]
    password: String,
}

fn main() {
    let user = User {
        username: "alice".to_string(),
        password: "secret123".to_string(),
    };

    // Serialize to JSON.
    let json = serde_json::to_string(&user).unwrap();

    // The output contains only the username.
    assert_eq!(json, r#"{"username":"alice"}"#);

    // The password is still in the struct.
    assert_eq!(user.password, "secret123");
}

The password field has the #[serde(skip_serializing)] attribute. When serde_json::to_string runs, Serde walks through the struct fields. It sees username and adds it to the output. It sees password, checks the attribute, and skips it. The resulting JSON has username but no password.

The User instance still holds the password. You can compare it, hash it, or pass it to a database driver. Serialization simply ignores it.

Smart skipping with conditions

Real code rarely has static skip rules. You often want to skip a field only if it is empty, or if it holds a default value. Sending {"message": ""} is wasteful. Sending {"message": null} might break a frontend that expects a string. You want the key to disappear when there is nothing to say.

Serde provides #[serde(skip_serializing_if = "...")]. You pass a function that returns a boolean. If the function returns true, Serde skips the field. If it returns false, Serde includes it.

use serde::Serialize;

/// An API response that omits empty fields to save bandwidth.
#[derive(Serialize)]
struct ApiResponse {
    /// Always included.
    status: String,

    /// Skip this field if the message is empty.
    #[serde(skip_serializing_if = "String::is_empty")]
    message: String,

    /// Skip this field if the list is empty.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    errors: Vec<String>,
}

fn main() {
    let response = ApiResponse {
        status: "ok".to_string(),
        message: String::new(),
        errors: vec![],
    };

    let json = serde_json::to_string(&response).unwrap();

    // Empty fields are omitted.
    assert_eq!(json, r#"{"status":"ok"}"#);
}

The attribute takes a function pointer. String::is_empty is a method on String, but Rust lets you pass it as a function pointer because the first argument is &self. Serde calls that function with a reference to the field value. If the string is empty, the function returns true, and the field is skipped.

Convention aside: use the function pointer form like String::is_empty instead of a closure like |s| s.is_empty(). Both compile and work. The function pointer is shorter, faster, and the community standard. It signals that you are using a well-known predicate rather than custom logic.

The Option pattern

JavaScript and Python developers often expect null in JSON. Rust uses Option<T>. By default, Serde serializes None to null. If you have Option<String>, the output might be {"name": null}.

Many APIs prefer missing keys over null. If the name is not set, the key should not appear. You can achieve this with skip_serializing_if.

use serde::Serialize;

/// A profile where optional fields disappear when absent.
#[derive(Serialize)]
struct Profile {
    id: u32,

    /// Skip this field if it is None.
    #[serde(skip_serializing_if = "Option::is_none")]
    nickname: Option<String>,
}

fn main() {
    let profile = Profile {
        id: 42,
        nickname: None,
    };

    let json = serde_json::to_string(&profile).unwrap();

    // The nickname key is missing, not null.
    assert_eq!(json, r#"{"id":42}"#);
}

This is the standard pattern for optional fields in Rust APIs. Option::is_none returns true when the value is None. Serde skips the field. The JSON has no nickname key. If you set nickname to Some("alice"), the function returns false, and Serde includes the field with the string value.

Convention aside: skip_serializing_if = "Option::is_none" is so common that many Rust developers consider it the default choice for Option fields. If you see an Option in a struct meant for JSON, check if it has this attribute. If it does not, the API might be sending null unnecessarily.

Pitfalls and round-trip failures

Skipping serialization creates a asymmetry. The struct has the field. The JSON does not. If you try to deserialize that JSON back into the same struct, Serde will complain.

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct Config {
    /// This field is skipped during serialization.
    #[serde(skip_serializing)]
    api_key: String,
}

fn main() {
    let config = Config {
        api_key: "secret".to_string(),
    };

    let json = serde_json::to_string(&config).unwrap();
    // json is "{}" because api_key is skipped.

    // This fails at runtime.
    let result: Result<Config, _> = serde_json::from_str(&json);
    // Error: missing field `api_key`
}

The serialization produces {}. The deserialization expects api_key. Serde cannot find it. You get a runtime error: missing field 'api_key'. This is not a compiler error. The code compiles fine. The failure happens when you run the program and try to parse the JSON.

You have two ways to fix this.

First, use #[serde(skip)] instead. This skips both serialization and deserialization. The field is completely ignored by Serde. It is useful for fields that are computed at runtime or loaded from elsewhere.

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct Config {
    /// Ignored for both reading and writing.
    #[serde(skip)]
    api_key: String,
}

Second, keep skip_serializing but add #[serde(default)]. This tells Serde that if the field is missing during deserialization, it should use the default value for the type. For String, the default is an empty string. For u32, it is 0.

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct Config {
    /// Skipped on write, defaults to empty on read.
    #[serde(skip_serializing, default)]
    api_key: String,
}

With default, the deserialization succeeds. The api_key field gets an empty string. You can then fill it in from a configuration file or environment variable after deserialization.

Pitfall alert: skip_serializing_if can also break round-trips. If you skip a field because it is empty, and you deserialize back, the field will be missing. You need #[serde(default)] on those fields too, or deserialization will fail with a missing field error.

Don't skip serialization without thinking about deserialization. The round-trip breaks if you leave a hole.

What happens under the hood

Serde is a macro-based library. When you write #[derive(Serialize)], the macro generates code that implements the Serialize trait. The generated code walks through every field and calls serializer.serialize_field.

The #[serde(...)] attributes are metadata. The derive macro reads this metadata and changes the generated code. If you add skip_serializing, the macro omits the call to serialize_field for that field. The generated code simply does not include the field in the output.

If you add skip_serializing_if, the macro generates a conditional check. It calls your predicate function. If the result is true, it skips the field. If false, it proceeds with serialization.

This means the skipping logic is resolved at compile time. There is no runtime overhead for checking attributes. The generated code is as fast as if you had written the serialization manually. The only cost is the predicate function call for skip_serializing_if, which is usually a simple check like is_empty.

Decision matrix

Choose the right attribute based on your needs.

Use #[serde(skip_serializing)] when a field contains sensitive data or internal state that must never leak into the serialized output. Use #[serde(skip_serializing_if = "predicate")] when you want to reduce payload size by omitting fields that hold empty, null, or default values. Use #[serde(skip)] when a field is purely for runtime logic and should be ignored during both serialization and deserialization. Use #[serde(default)] on a field when you skip serialization but still need to deserialize into the struct, ensuring the missing field gets a safe fallback value.

Where to go next