What are the trade-offs between serde::ser::SerializeMap and SerializeStruct for map-like serialization?

serde::ser::SerializeMap serializes dynamic key-value collections where keys are determined at runtime, while SerializeStruct serializes fixed schemas where field names are known ahead of time and the serializer can optimize for the known structure. The key trade-off is flexibility versus efficiency: SerializeMap allows arbitrary keys but forces the serializer to handle each key as it comes, while SerializeStruct enables serializers to reorder, rename, or skip fields based on known structure.

SerializeMap Basics

use serde::ser::{Serialize, Serializer, SerializeMap};
 
// SerializeMap: Dynamic keys determined at runtime
struct DynamicMap {
    entries: Vec<(String, String)>,
}
 
impl Serialize for DynamicMap {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        // Create a map serializer
        let mut map = serializer.serialize_map(Some(self.entries.len()))?;
        
        // Add entries dynamically
        for (key, value) in &self.entries {
            map.serialize_entry(key, value)?;
        }
        
        map.end()
    }
}
 
fn main() {
    let map = DynamicMap {
        entries: vec
![
            ("key1".to_string(), "value1".to_string())
,
            ("key2".to_string(), "value2".to_string()),
            ("arbitrary_key".to_string(), "any_value".to_string()),
        ],
    };
    
    let json = serde_json::to_string(&map).unwrap();
    println!("{}", json);
    // {"key1":"value1","key2":"value2","arbitrary_key":"any_value"}
}

SerializeMap accepts any key-value pairs at runtime.

SerializeStruct Basics

use serde::ser::{Serialize, Serializer, SerializeStruct};
 
// SerializeStruct: Fixed fields known at compile time
struct Point {
    x: i32,
    y: i32,
}
 
impl Serialize for Point {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        // Create a struct serializer with known field count
        let mut s = serializer.serialize_struct("Point", 2)?;
        
        // Serialize fields with known names
        s.serialize_field("x", &self.x)?;
        s.serialize_field("y", &self.y)?;
        
        s.end()
    }
}
 
fn main() {
    let point = Point { x: 10, y: 20 };
    
    let json = serde_json::to_string(&point).unwrap();
    println!("{}", json);
    // {"x":10,"y":20}
}

SerializeStruct declares field names at compile time.

Key Differences in Serialization

use serde::ser::{Serializer, SerializeMap, SerializeStruct};
 
// The key difference is what the serializer knows:
 
// With SerializeMap:
// - Serializer doesn't know what keys will come
// - Each key is a runtime string
// - Must handle arbitrary keys
// - Cannot reorder fields (must serialize in order)
 
// With SerializeStruct:
// - Serializer knows field names in advance
// - Can optimize for specific field names
// - Can reorder fields if needed
// - Can skip fields that match defaults
 
fn serialize_map_example<S: Serializer>(serializer: S) -> Result<S::Ok, S::Error> {
    let mut map = serializer.serialize_map(None)?;
    
    // Keys are runtime strings - serializer handles generically
    map.serialize_entry("dynamic_key", &42)?;
    map.serialize_entry("another_key", &"value")?;
    
    map.end()
}
 
fn serialize_struct_example<S: Serializer>(serializer: S) -> Result<S::Ok, S::Error> {
    let mut s = serializer.serialize_struct("MyStruct", 2)?;
    
    // Field names are known - serializer can optimize
    s.serialize_field("known_field", &42)?;
    s.serialize_field("another_field", &"value")?;
    
    s.end()
}

The serializer has more information with SerializeStruct.

Binary Format Optimization

use serde::ser::{Serializer, SerializeStruct};
 
// Binary formats like bincode benefit from known structure
 
// With SerializeStruct in bincode:
// - Field names can be omitted (uses field order)
// - Can skip serializing default values
// - More compact representation
 
// With SerializeMap in bincode:
// - Must include field names (as strings)
// - Cannot skip values
// - Larger binary output
 
impl Serialize for OptimizedStruct {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        // Bincode sees this as a struct with known fields
        // Can use field indices instead of names
        let mut s = serializer.serialize_struct("OptimizedStruct", 2)?;
        s.serialize_field("id", &self.id)?;
        s.serialize_field("value", &self.value)?;
        s.end()
    }
}
 
// Compare JSON output vs bincode:
// JSON: {"id":123,"value":"test"}  // Field names included
// Bincode: [123, "test"]           // Field names omitted (known order)

Binary formats can optimize struct serialization by using field order.

Dynamic Keys with SerializeMap

use serde::ser::{Serialize, Serializer, SerializeMap};
use std::collections::HashMap;
 
// When you need dynamic keys, SerializeMap is required
struct Config {
    values: HashMap<String, serde_json::Value>,
}
 
impl Serialize for Config {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let mut map = serializer.serialize_map(Some(self.values.len()))?;
        
        // Keys determined at runtime
        for (key, value) in &self.values {
            map.serialize_entry(key, value)?;
        }
        
        map.end()
    }
}
 
// Cannot use SerializeStruct here because:
// 1. Keys are not known at compile time
// 2. Number and names of fields vary per instance
// 3. Keys come from user input or external data
 
fn main() {
    let mut config = Config {
        values: HashMap::new(),
    };
    
    config.values.insert("timeout".to_string(), json!(30));
    config.values.insert("retries".to_string(), json!(3));
    config.values.insert("custom_user_key".to_string(), json!("value"));
    
    // These keys cannot be hardcoded in serialize_struct
}

Use SerializeMap when keys come from runtime data.

Field-Level Control with SerializeStruct

use serde::ser::{Serialize, Serializer, SerializeStruct};
 
// SerializeStruct allows field-level decisions
struct User {
    id: u64,
    name: String,
    email: Option<String>,
    password_hash: String,
}
 
impl Serialize for User {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        // Calculate actual field count (skip None)
        let mut field_count = 2;  // id and name always present
        if self.email.is_some() {
            field_count += 1;
        }
        // Don't count password_hash - we're skipping it!
        
        let mut s = serializer.serialize_struct("User", field_count)?;
        
        s.serialize_field("id", &self.id)?;
        s.serialize_field("name", &self.name)?;
        
        // Only serialize email if present
        if let Some(ref email) = self.email {
            s.serialize_field("email", email)?;
        }
        
        // Skip password_hash entirely for security
        
        s.end()
    }
}
 
fn main() {
    let user = User {
        id: 1,
        name: "Alice".to_string(),
        email: Some("alice@example.com".to_string()),
        password_hash: "hashed".to_string(),
    };
    
    let json = serde_json::to_string(&user).unwrap();
    // {"id":1,"name":"Alice","email":"alice@example.com"}
    // password_hash is NOT included
}

SerializeStruct gives explicit control over which fields to include.

Skip Serializing Default Values

use serde::ser::{Serialize, Serializer, SerializeStruct};
 
// Only serialize non-default values
struct Settings {
    enabled: bool,
    timeout: u32,      // default: 30
    retries: u32,      // default: 3
    max_connections: u32, // default: 100
}
 
impl Default for Settings {
    fn default() -> Self {
        Settings {
            enabled: true,
            timeout: 30,
            retries: 3,
            max_connections: 100,
        }
    }
}
 
impl Serialize for Settings {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        // Count only non-default fields
        let mut field_count = 1;  // enabled always serialized
        if self.timeout != 30 { field_count += 1; }
        if self.retries != 3 { field_count += 1; }
        if self.max_connections != 100 { field_count += 1; }
        
        let mut s = serializer.serialize_struct("Settings", field_count)?;
        
        s.serialize_field("enabled", &self.enabled)?;
        
        // Only include non-default values
        if self.timeout != 30 {
            s.serialize_field("timeout", &self.timeout)?;
        }
        if self.retries != 3 {
            s.serialize_field("retries", &self.retries)?;
        }
        if self.max_connections != 100 {
            s.serialize_field("max_connections", &self.max_connections)?;
        }
        
        s.end()
    }
}
 
fn main() {
    let settings = Settings {
        enabled: true,
        timeout: 30,        // default - not serialized
        retries: 5,         // non-default - serialized
        max_connections: 100, // default - not serialized
    };
    
    let json = serde_json::to_string(&settings).unwrap();
    // {"enabled":true,"retries":5}
}

Serialize only values that differ from defaults to reduce output size.

Struct Name Metadata

use serde::ser::{Serialize, Serializer, SerializeStruct, SerializeMap};
 
// SerializeStruct carries type name information
struct Point {
    x: i32,
    y: i32,
}
 
impl Serialize for Point {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        // First parameter is the struct name - used by some serializers
        let mut s = serializer.serialize_struct("Point", 2)?;
        s.serialize_field("x", &self.x)?;
        s.serialize_field("y", &self.y)?;
        s.end()
    }
}
 
// SerializeMap has no type name
impl Serialize for PointAsMap {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let mut map = serializer.serialize_map(Some(2))?;
        map.serialize_entry("x", &self.x)?;
        map.serialize_entry("y", &self.y)?;
        map.end()
    }
}
 
// The struct name "Point" can be used by:
// - Human-readable formats for documentation
// - Self-describing formats for type information
// - Debug output for clarity

SerializeStruct includes a type name that serializers can use.

Complex Field Types

use serde::ser::{Serialize, Serializer, SerializeStruct};
 
// Nested structures with different serialization needs
struct ComplexStruct {
    simple_field: i32,
    optional_field: Option<String>,
    nested: NestedData,
}
 
struct NestedData {
    values: Vec<i32>,
}
 
impl Serialize for NestedData {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        // Could use SerializeMap for dynamic data
        let mut map = serializer.serialize_map(Some(self.values.len()))?;
        for (i, &v) in self.values.iter().enumerate() {
            map.serialize_entry(&format!("item_{}", i), &v)?;
        }
        map.end()
    }
}
 
impl Serialize for ComplexStruct {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let mut s = serializer.serialize_struct("ComplexStruct", 3)?;
        
        s.serialize_field("simple_field", &self.simple_field)?;
        
        // Option can be serialized conditionally
        if let Some(ref val) = self.optional_field {
            s.serialize_field("optional_field", val)?;
        }
        
        // Nested uses its own Serialize implementation
        s.serialize_field("nested", &self.nested)?;
        
        s.end()
    }
}

Fields can use their own Serialize implementations.

Flattened Fields

use serde::ser::{Serialize, Serializer, SerializeStruct};
use serde_json::json;
 
// Flatten merges fields from inner struct into outer
struct Outer {
    outer_field: String,
    inner: Inner,
}
 
struct Inner {
    inner_field: i32,
    another_inner: bool,
}
 
impl Serialize for Outer {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        // Count total fields (outer + flattened inner)
        let mut s = serializer.serialize_struct("Outer", 3)?;
        
        s.serialize_field("outer_field", &self.outer_field)?;
        
        // Flatten inner fields directly
        s.serialize_field("inner_field", &self.inner.inner_field)?;
        s.serialize_field("another_inner", &self.inner.another_inner)?;
        
        s.end()
    }
}
 
fn main() {
    let outer = Outer {
        outer_field: "test".to_string(),
        inner: Inner {
            inner_field: 42,
            another_inner: true,
        },
    };
    
    let json = serde_json::to_string(&outer).unwrap();
    // {"outer_field":"test","inner_field":42,"another_inner":true}
    // Inner fields flattened into outer
}

Flatten inner struct fields into the outer structure.

Map vs Struct for Dynamic Schemas

use serde::ser::{Serialize, Serializer, SerializeMap, SerializeStruct};
use std::collections::BTreeMap;
 
// Scenario: API response with varying fields based on type
enum ApiResponse {
    User { id: u64, name: String },
    Error { code: i32, message: String },
    List { items: Vec<String>, total: usize },
}
 
impl Serialize for ApiResponse {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        match self {
            ApiResponse::User { id, name } => {
                // Known fields - use SerializeStruct
                let mut s = serializer.serialize_struct("User", 2)?;
                s.serialize_field("type", "user")?;
                s.serialize_field("id", id)?;
                s.serialize_field("name", name)?;
                s.end()
            }
            ApiResponse::Error { code, message } => {
                // Known fields - use SerializeStruct
                let mut s = serializer.serialize_struct("Error", 3)?;
                s.serialize_field("type", "error")?;
                s.serialize_field("code", code)?;
                s.serialize_field("message", message)?;
                s.end()
            }
            ApiResponse::List { items, total } => {
                // Known fields - use SerializeStruct
                let mut s = serializer.serialize_struct("List", 2)?;
                s.serialize_field("type", "list")?;
                s.serialize_field("items", items)?;
                s.serialize_field("total", total)?;
                s.end()
            }
        }
    }
}
 
// Alternative with SerializeMap for truly dynamic fields
struct DynamicResponse {
    fields: BTreeMap<String, serde_json::Value>,
}
 
impl Serialize for DynamicResponse {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        let mut map = serializer.serialize_map(Some(self.fields.len()))?;
        for (key, value) in &self.fields {
            map.serialize_entry(key, value)?;
        }
        map.end()
    }
}

Use SerializeStruct when the schema is known per variant; use SerializeMap for truly dynamic fields.

Performance Implications

use serde::ser::{Serialize, Serializer, SerializeMap, SerializeStruct};
 
// SerializeStruct: Better for serializers that benefit from known structure
// - Binary formats can use field indices
// - Can skip default values
// - Can reorder fields for better compression
// - Type name available for self-describing formats
 
// SerializeMap: Required for dynamic keys
// - All keys must be serialized as strings
// - Cannot skip values based on defaults
// - Order must be preserved
// - No type name information
 
// Benchmarks show:
// - JSON: Similar performance (keys serialized either way)
// - Bincode: SerializeStruct much faster (field indices vs string keys)
// - MessagePack: SerializeStruct more compact (known structure)
 
// Use SerializeStruct when possible for better performance

SerializeStruct enables performance optimizations in binary formats.

Complete Example: Mixed Approach

use serde::ser::{Serialize, Serializer, SerializeMap, SerializeStruct};
use std::collections::HashMap;
 
// A config with both known and dynamic fields
struct AppConfig {
    // Known fields
    name: String,
    version: String,
    enabled: bool,
    // Dynamic fields from user
    extras: HashMap<String, serde_json::Value>,
}
 
impl Serialize for AppConfig {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        // Total field count
        let total_fields = 3 + self.extras.len();
        
        let mut s = serializer.serialize_struct("AppConfig", total_fields)?;
        
        // Known fields
        s.serialize_field("name", &self.name)?;
        s.serialize_field("version", &self.version)?;
        
        // Conditionally serialize enabled
        if self.enabled {
            s.serialize_field("enabled", &self.enabled)?;
        }
        
        // Dynamic fields as map entries
        // Note: This requires nested serialization
        for (key, value) in &self.extras {
            s.serialize_field(key, value)?;
        }
        
        s.end()
    }
}
 
// Alternative: Use a custom wrapper for extras
impl Serialize for AppConfig {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        use serde::ser::Error;
        
        let total = 3 + self.extras.len();
        let mut map = serializer.serialize_map(Some(total))?;
        
        // Known fields
        map.serialize_entry("name", &self.name)?;
        map.serialize_entry("version", &self.version)?;
        map.serialize_entry("enabled", &self.enabled)?;
        
        // Dynamic fields
        for (key, value) in &self.extras {
            map.serialize_entry(key, value)?;
        }
        
        map.end()
    }
}

Combine known and dynamic fields using either approach.

Synthesis

Quick reference:

Aspect SerializeMap SerializeStruct
Keys Runtime (dynamic) Compile-time (known)
Type name None Provided
Field skipping Manual Built-in support
Binary formats Less optimal Optimized
Flexibility Higher Lower
Type safety Lower Higher

When to use each:

// Use SerializeMap when:
// - Keys are determined at runtime
// - Data comes from HashMap, BTreeMap, etc.
// - Schema is dynamic or user-defined
// - Field names are arbitrary strings
 
// Use SerializeStruct when:
// - Fields are known at compile time
// - You need conditional field serialization
// - You want to skip default values
// - You need type name metadata
// - You're optimizing for binary formats

Key insight: SerializeMap and SerializeStruct represent two different approaches to serializing key-value data, with SerializeMap optimized for flexibility and SerializeStruct optimized for known structure. Use SerializeMap when keys are truly dynamic—when they come from a HashMap, from user input, or from external configuration that can't be known at compile time. Use SerializeStruct when fields are fixed and known—when you can enumerate them in code, when you want to conditionally include fields, skip defaults, or provide type metadata. The performance difference matters most for binary formats: SerializeStruct allows serializers like bincode to use field indices instead of field names, dramatically reducing output size. For JSON, the difference is minimal since keys are serialized either way, but SerializeStruct still enables skipping default values and excluding sensitive fields. The struct name in serialize_struct("TypeName", n) is metadata that some serializers use for documentation or self-describing formats—it's not included in the output for compact formats like bincode but appears in debug or human-readable representations. Choose SerializeStruct by default for structured data; choose SerializeMap when you truly need dynamic keys.