How to Deserialize into a HashMap with Serde
You're parsing a configuration file where the keys change at runtime. Maybe it's a plugin system where each plugin registers its own settings, or a localization file where language codes vary. Your JSON looks like {"en": "Hello", "fr": "Bonjour"}, but you don't know which languages exist until you read the file. A struct with fixed fields won't work here. You need a collection that maps arbitrary keys to values. That's where HashMap comes in.
Concept: Dynamic keys and value mapping
Serde treats a JSON object as a map by default. When you ask for a HashMap, Serde iterates over every key in the JSON object, deserializes the key into your map's key type, deserializes the value into your map's value type, and inserts the pair.
Think of it like a phone book. You don't care about the order of entries. You care that when you look up "Alice", you get her number. Serde builds that book for you from the JSON data. The keys can be anything that implements Deserialize, but in JSON, keys are always strings on the wire. Serde handles the conversion from string to your target key type automatically.
Minimal example
Define a struct with a HashMap field and derive Deserialize. Serde matches the JSON field name to the struct field and populates the map.
use serde::Deserialize;
use std::collections::HashMap;
/// Configuration holding dynamic score entries.
#[derive(Deserialize)]
struct Config {
/// Maps team names to their integer scores.
/// Keys are Strings, values are i32.
scores: HashMap<String, i32>,
}
fn main() {
let json = r#"{"scores": {"Blue": 10, "Yellow": 50}}"#;
// Parse JSON into Config. Serde matches "scores" to the field.
// The nested object becomes the HashMap.
let config: Config = serde_json::from_str(json).unwrap();
println!("{:?}", config.scores);
}
The HashMap field works out of the box. Serde sees the JSON object {"Blue": 10, "Yellow": 50} and populates the map. The keys become String values, and the values become i32 integers.
Serde maps JSON objects to HashMap automatically. Trust the type system to catch value mismatches, but watch your key types at runtime.
Walkthrough: What happens under the hood
When you add #[derive(Deserialize)], the macro generates an implementation of the Deserialize trait for Config. This generated code knows how to handle HashMap.
At runtime, serde_json::from_str parses the JSON stream. It encounters the "scores" key, creates an empty HashMap, and then loops over the nested object. For each entry, it deserializes the key string, deserializes the value integer, and inserts the pair into the map.
If the JSON contains a key that isn't a string or a value that isn't an integer, the deserialization fails with an error. The derive macro does the heavy lifting. Focus on your data types, not the parsing loop.
Realistic example: Nested structures and defaults
Real configs often have nested structures or complex values. You might have a map of user profiles, where each profile has a name and an email. Or you might need to handle missing keys gracefully.
use serde::Deserialize;
use std::collections::HashMap;
/// A user profile with nested data.
#[derive(Deserialize, Debug)]
struct User {
name: String,
email: String,
}
/// System configuration with dynamic user entries.
#[derive(Deserialize, Debug)]
struct SystemConfig {
/// Maps user IDs to their profile details.
users: HashMap<String, User>,
/// Feature flags with boolean values.
/// Defaults to an empty map if missing.
#[serde(default)]
features: HashMap<String, bool>,
}
fn main() {
let json = r#"
{
"users": {
"u1": {"name": "Alice", "email": "alice@example.com"},
"u2": {"name": "Bob", "email": "bob@example.com"}
}
}
"#;
// Deserialize with error handling.
let config: SystemConfig = serde_json::from_str(json)
.expect("Valid config");
println!("{:#?}", config);
}
The #[serde(default)] attribute on features handles missing keys. If the JSON omits the "features" object entirely, Serde inserts an empty HashMap instead of failing. This is a standard pattern for configuration files where optional sections might not appear.
Use #[serde(default)] for optional sections. It keeps your config loader robust against missing keys.
Pitfalls and compiler errors
JSON keys are always strings in the wire format. If you declare HashMap<i32, String>, Serde attempts to parse each string key as an integer. Keys like "123" succeed. Keys like "user_id" cause a runtime deserialization error. You cannot catch this at compile time.
HashMap does not preserve insertion order. If your logic depends on processing keys in the order they appear in the JSON, HashMap will break your assumptions. The iteration order is effectively random and can change between runs.
If you forget #[derive(Deserialize)], the compiler rejects the code with E0277 (the trait bound Deserialize is not satisfied).
If order matters, switch to BTreeMap. HashMap gives you speed, not sequence.
Advanced patterns: Enum keys and IndexMap
You can use enums as map keys. This is common for typed configurations where keys represent specific states or categories. Serde deserializes the string key into the enum variant.
use serde::Deserialize;
use std::collections::HashMap;
/// Represents color categories.
#[derive(Deserialize, Debug, Hash, Eq, PartialEq)]
enum Color {
Red,
Green,
Blue,
}
/// Palette mapping colors to hex values.
#[derive(Deserialize, Debug)]
struct Palette {
colors: HashMap<Color, u32>,
}
fn main() {
let json = r#"{"colors": {"Red": 16711680, "Blue": 255}}"#;
let palette: Palette = serde_json::from_str(json).unwrap();
println!("{:?}", palette);
}
The enum must derive Deserialize, Hash, Eq, and PartialEq. Serde parses "Red" into Color::Red. This gives you type safety for keys while keeping the flexibility of a map.
The community often reaches for IndexMap from the indexmap crate when they need insertion order without sacrificing lookup speed. IndexMap keeps a vector of entries and a hash map for lookup. It provides O(1) lookup like HashMap but preserves order like a vector.
Add indexmap to your Cargo.toml. Serde supports IndexMap out of the box. It bridges the gap between HashMap and BTreeMap.
Reach for IndexMap when you need order and speed. It's the community standard for ordered maps in Rust.
Decision: When to use HashMap vs alternatives
Use HashMap<String, T> when keys vary at runtime and you prioritize lookup speed over order.
Use BTreeMap<String, T> when you require sorted keys or deterministic iteration order.
Use a struct with named fields when the JSON schema is stable and you want the compiler to catch missing or extra keys.
Use IndexMap<String, T> when you need insertion order and fast lookups, and you are willing to add a dependency.
Use Vec<(String, T)> when the input might contain duplicate keys and you need to preserve every entry, since maps overwrite duplicates.