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 claritySerializeStruct 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 performanceSerializeStruct 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 formatsKey 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.
