How to skip fields in serde serialization

Use the #[serde(skip_serializing)] attribute on a struct field to exclude it from serialization output.

When data shouldn't travel

You're building an API endpoint that returns user profiles. Your User struct holds the username, email, and a password hash loaded from the database. You call serde_json::to_string(&user) to send the response. The JSON includes the password hash. That's a security breach waiting to happen. Or imagine a struct with a cache field that's expensive to compute. You serialize the struct to save it to disk, and suddenly your cache is baked into the file, wasting space and potentially becoming stale when the underlying data changes.

Serde maps Rust types to data formats by default. Every field in the struct becomes a key-value pair in the output. Attributes let you override that behavior. Skipping fields is the most common override. It tells serde to leave specific data behind when it crosses the boundary between Rust and the outside world.

The mechanics of skipping

Serde treats your struct like a form. By default, every field on the form gets written into the output. Attributes are instructions to the form-filler. #[serde(skip_serializing)] tells serde to ignore a field when writing the output. The field still exists in memory. It just doesn't appear in the JSON, YAML, or binary blob.

use serde::Serialize;

#[derive(Serialize)]
struct User {
    username: String,
    // Skip this field during serialization to prevent leaking sensitive data.
    #[serde(skip_serializing)]
    password_hash: String,
}

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

    // Output: {"username":"alice"}
    let json = serde_json::to_string(&user).unwrap();
    println!("{}", json);
}

The password_hash field lives in the User instance. It's accessible via user.password_hash. It simply doesn't get on the bus when serialization happens. The output contains only username.

The field stays in RAM. It just doesn't get on the bus.

Directionality matters

Serde has two distinct modes. Serialization turns Rust data into a format. Deserialization turns a format back into Rust data. Attributes can target one direction, the other, or both. skip_serializing only affects the outbound trip. The field is still required on the inbound trip unless you add more attributes.

This distinction catches many developers off guard. If you skip serializing a field, the output won't contain it. If you try to deserialize that output back into the same struct, serde looks for the field and fails. You get a deserialization error because the data is missing.

Serialization and deserialization are two different doors. Locking one doesn't lock the other.

Conditional skipping

Sometimes you don't want to skip a field always. You want to skip it only when it holds a specific value. #[serde(skip_serializing_if = "predicate")] takes a function reference. Serde calls that function with a reference to the field. If the function returns true, the field is skipped. If it returns false, the field is included.

This is the standard way to handle optional data without emitting null.

use serde::Serialize;

#[derive(Serialize)]
struct Config {
    host: String,
    // Skip the proxy field if it is None.
    #[serde(skip_serializing_if = "Option::is_none")]
    proxy: Option<String>,
    // Skip the tags field if the vector is empty.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    tags: Vec<String>,
}

fn main() {
    let config = Config {
        host: "api.example.com".to_string(),
        proxy: None,
        tags: vec![],
    };

    // Output: {"host":"api.example.com"}
    let json = serde_json::to_string(&config).unwrap();
    println!("{}", json);
}

The predicate function must have the signature fn(&T) -> bool, where T is the type of the field. Option::is_none and Vec::is_empty are built-in methods that match this signature perfectly. You can also write custom functions.

fn is_default<T: Default + PartialEq>(val: &T) -> bool {
    val == &T::default()
}

#[derive(Serialize)]
struct Settings {
    #[serde(skip_serializing_if = "is_default")]
    timeout: u64,
}

Convention aside: #[serde(skip_serializing_if = "Option::is_none")] is idiomatic Rust. The community expects optional fields to be omitted when None rather than serialized as null. Using this attribute signals that you understand the convention.

The round-trip trap

Skipping serialization creates a gap in the data flow. If you skip a field during serialization, the output lacks that field. If you deserialize the output back into the struct, serde expects the field to be present. It isn't. Deserialization fails with a "missing field" error.

You have three ways to fix this, depending on your intent.

Use #[serde(default)] if you want the field to be optional during deserialization. Serde will use the type's Default implementation when the field is missing.

Use #[serde(skip_deserializing)] if the field is never meant to come from the data source. This is common for computed fields or transient state.

Use #[serde(skip)] if you want to ignore the field in both directions. This is shorthand for skip_serializing and skip_deserializing.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct UserProfile {
    username: String,
    // Skip serializing to hide the hash.
    // Use default to allow deserialization when the field is missing.
    #[serde(skip_serializing, default)]
    password_hash: String,
    // Computed field. Never serialized, never deserialized.
    #[serde(skip)]
    is_admin: bool,
}

fn main() {
    let json = r#"{"username":"bob"}"#;
    // This works because password_hash has default, and is_admin is skipped.
    let profile: UserProfile = serde_json::from_str(json).unwrap();
    assert_eq!(profile.password_hash, "");
    assert!(!profile.is_admin);
}

The default attribute provides a fallback. For String, the default is an empty string. For Option<T>, the default is None. This allows the struct to be reconstructed even when the data is incomplete.

If you skip serializing but forget to handle deserialization, you'll spend hours debugging missing fields.

Realistic example: The API response

Real-world structs often mix several strategies. You might have sensitive data, optional data, computed data, and transient data all in one place. Here's a pattern that handles all of them.

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
struct ApiResponse {
    // Core data. Always included.
    id: u64,
    name: String,
    
    // Sensitive data. Never sent to the client.
    // Deserialization uses default because the client won't send it back.
    #[serde(skip_serializing, default)]
    api_key: String,
    
    // Optional metadata. Omitted if None.
    #[serde(skip_serializing_if = "Option::is_none")]
    description: Option<String>,
    
    // Computed timestamp. Generated by the server, ignored on input.
    #[serde(skip_deserializing)]
    created_at: u64,
    
    // Transient cache. Internal only.
    #[serde(skip)]
    cache: Vec<u8>,
}

impl ApiResponse {
    fn new(id: u64, name: String, api_key: String) -> Self {
        Self {
            id,
            name,
            api_key,
            description: None,
            created_at: std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_secs(),
            cache: Vec::new(),
        }
    }
}

fn main() {
    let response = ApiResponse::new(1, "Widget".to_string(), "secret_key".to_string());
    
    // Serialization omits api_key, description (None), created_at, and cache.
    let json = serde_json::to_string(&response).unwrap();
    println!("{}", json);
    // Output: {"id":1,"name":"Widget"}
    
    // Deserialization works because api_key has default, created_at is skipped,
    // and cache is skipped.
    let restored: ApiResponse = serde_json::from_str(&json).unwrap();
    assert_eq!(restored.id, 1);
    assert_eq!(restored.api_key, ""); // Default value
    assert_eq!(restored.name, "Widget");
}

This struct is safe to serialize and deserialize. Sensitive data stays hidden. Optional data doesn't clutter the output. Computed fields don't cause errors. Transient state doesn't waste bandwidth.

Pick the attribute that matches the data flow. Mismatch them and you'll spend hours debugging missing fields.

Pitfalls and gotchas

Skipping fields is simple, but a few patterns cause trouble.

Using #[serde(skip_serializing)] on an Option<T> field is rarely what you want. This skips the field entirely, even if it contains Some(value). The output will never include the key. If you want to omit None but include Some, use skip_serializing_if = "Option::is_none".

Confusing skip with skip_serializing leads to silent data loss. #[serde(skip)] removes the field from both serialization and deserialization. If you only meant to hide it from the output, you've also broken the ability to read it back. Use skip only for fields that are purely internal and never touch the wire.

Forgetting default when skipping serialization causes runtime errors. The compiler won't catch this. The error appears when deserialization runs. You'll see Error("missing field 'x'"). Adding default or skip_deserializing fixes it.

Using skip_serializing_if with a function that has the wrong signature causes a compile error. The function must take &T and return bool. If you try to pass a function that takes T by value, the compiler rejects it with a type mismatch error. Ensure the function signature matches the field type.

Serialization and deserialization are two different doors. Locking one doesn't lock the other.

Decision matrix

Choose the attribute based on how the data moves.

Use #[serde(skip_serializing)] when the field must stay in memory but never appear in the output, like a password hash or a cached value.

Use #[serde(skip_deserializing)] when the field is computed or transient and should be ignored when reading data, like a validation flag or a derived timestamp.

Use #[serde(skip)] when you want to ignore the field entirely in both directions, effectively making it invisible to serde.

Use #[serde(skip_serializing_if = "predicate")] when the field should only appear under specific conditions, such as omitting None values or empty strings.

Use #[serde(default)] alongside skip attributes when you need to deserialize data that lacks the field, providing a fallback value so the struct can be reconstructed.

Convention aside: #[serde(skip)] is preferred over #[serde(skip_serializing, skip_deserializing)] for brevity. The community treats skip as the standard way to mark a field as internal-only.

Where to go next