The config file that kills your server
You are deploying a web service. The configuration lives in a JSON file. You type the port as "8080" and the host as "localhost". The server starts. Everything works. Two weeks later, an operator edits the file and changes the port to "eight-thousand". Or they accidentally delete the host key. The server starts, tries to bind, and crashes with a panic that buries the real cause in a stack trace.
In dynamic languages, you might add a check like if not port: exit(). In Rust, you can do better. You can make the configuration loading fail fast with a clear error message, or even prevent invalid structures at compile time. The goal is to turn configuration errors into explicit results that you handle, not silent failures that crash your process later.
Parsing versus validating
Parsing turns text into data. Validation checks if that data makes sense. serde is the standard crate for parsing. It reads JSON, TOML, or YAML and maps the values into Rust structs. serde is excellent at parsing, but it doesn't know your business rules. It doesn't know that a port must be non-zero, or that a URL must start with http, or that max_connections should be greater than min_connections.
Think of parsing like a receptionist reading a name tag. The receptionist can read the text and hand you a card. Validation is the bouncer checking if the person on the card is actually allowed in. serde handles the receptionist work. You have to teach the bouncer the rules.
Rust gives you three layers of validation. The first layer is the type system. A u16 field rejects values outside 0 to 65535 automatically. The second layer is serde attributes. You can tell serde to reject unknown keys or require specific formats. The third layer is custom logic. You write a method that checks constraints the type system can't express.
Minimal example: Types as validators
Start with a struct that derives Deserialize. serde generates the code to read JSON and fill the fields. The types of the fields act as the first line of defense.
use serde::Deserialize;
/// Configuration for the application.
#[derive(Deserialize, Debug)]
struct Config {
/// Port must be between 0 and 65535.
port: u16,
/// Host is a string; serde accepts any string.
host: String,
}
fn main() {
let json = r#"{"port": 8080, "host": "localhost"}"#;
// serde_json::from_str returns a Result.
// If the JSON is malformed or types don't match, this is an Err.
let config: Config = serde_json::from_str(json)
.expect("Failed to parse config");
println!("Config loaded: {config:?}");
}
If the JSON contains "port": 70000, serde_json::from_str returns an error. The u16 type enforces the range. You don't need to write if port > 65535. The deserializer catches it. If the JSON contains "port": "abc", the error is also caught. The type system is doing validation work for free.
Types are cheap validation. Use them.
How serde maps data to structs
When you call serde_json::from_str, serde walks through the JSON object. It looks for keys that match the field names in your struct. If it finds a match, it tries to deserialize the value into the field's type. If the value doesn't fit the type, deserialization fails.
By default, serde ignores extra fields in the JSON. If your config has a typo like "prot": 8080, serde sees a missing port field and fails because port is required. The typo is silently ignored. This can be confusing. The error message says missing field port, but the real problem is a typo.
You can change this behavior with an attribute.
use serde::Deserialize;
/// Configuration that rejects typos in field names.
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct Config {
port: u16,
host: String,
}
With deny_unknown_fields, if the JSON contains "prot", serde returns an error saying unknown field prot. This catches typos immediately. It also prevents old config files from silently working with new code that has removed fields.
Deny unknown fields. Typos in config files are silent killers.
Realistic example: Business logic validation
Types and attributes handle structure and ranges. They don't handle logic. You need a way to check that host isn't empty, or that max_connections is reasonable. The idiomatic pattern is to add a validate method to your config struct. This method takes &self and returns a Result.
use serde::Deserialize;
use std::fmt;
/// Custom error type for configuration validation.
#[derive(Debug)]
struct ConfigError(String);
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Config error: {}", self.0)
}
}
impl std::error::Error for ConfigError {}
/// Application configuration with validation rules.
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
struct Config {
port: u16,
host: String,
max_connections: u32,
}
impl Config {
/// Validates business rules after deserialization.
fn validate(&self) -> Result<(), ConfigError> {
if self.host.is_empty() {
return Err(ConfigError("Host cannot be empty".into()));
}
if self.max_connections == 0 {
return Err(ConfigError("Max connections must be greater than 0".into()));
}
Ok(())
}
}
fn load_config(json: &str) -> Result<Config, Box<dyn std::error::Error>> {
// Parse the JSON first.
let config: Config = serde_json::from_str(json)?;
// Run custom validation.
config.validate()?;
Ok(config)
}
fn main() {
let json = r#"{"port": 8080, "host": "localhost", "max_connections": 100}"#;
match load_config(json) {
Ok(config) => println!("Valid config: {config:?}"),
Err(e) => eprintln!("Configuration error: {e}"),
}
}
The load_config function chains the parsing and validation. If parsing fails, the ? operator returns the error immediately. If parsing succeeds but validation fails, the validate call returns an error. The caller gets a single Result that covers all failure modes.
Validation errors should stop the program from starting. Never silently fall back to a default for a missing host.
Convention: Defaults and optional fields
Configuration often has optional settings. If a field is missing, you might want to use a default value. serde supports this with #[serde(default)].
use serde::Deserialize;
#[derive(Deserialize, Debug)]
struct Config {
port: u16,
/// Defaults to 100 if not present in JSON.
#[serde(default = "default_max_connections")]
max_connections: u32,
}
fn default_max_connections() -> u32 {
100
}
When max_connections is missing from the JSON, serde calls default_max_connections and uses the return value. The field is never missing in the struct. This changes the validation contract. You no longer need to check for missing values, but you should still validate the value if it is present. If the user provides 0, the validate method should still catch it.
Convention aside: The community prefers explicit defaults over implicit ones. Using #[serde(default = "fn")] makes the default value visible in the code. Using #[serde(default)] relies on the type's Default impl, which can be less obvious. Write the function. It documents the intent.
Pitfalls and compiler errors
If you forget to derive Deserialize on a struct, the compiler rejects the code with E0277 (the trait bound Config: serde::Deserialize is not satisfied). This is a compile-time error, which is good. You fix it before running.
If you use a type that doesn't implement Deserialize as a field, you also get E0277. For example, if you try to use a custom enum without deriving Deserialize, the compiler stops you.
Runtime errors come from serde_json::from_str. This function returns a Result<Config, serde_json::Error>. If you ignore the error with .unwrap() or .expect(), your program panics on bad config. In production code, handle the error. Log it and exit with a non-zero status code. A misconfigured server is worse than a stopped one.
Another pitfall is validating in the wrong order. If you validate before parsing, you can't access the parsed values. Always parse first, then validate. The load_config pattern above does this correctly.
Treat deserialization errors as fatal configuration bugs. Log the error and exit. A misconfigured server is worse than a stopped one.
Decision: Choosing your validation strategy
Use serde type constraints when the validation rule matches a Rust type. A u16 field rejects values outside 0 to 65535 automatically. A bool field rejects "yes" or "1". Let the compiler and the deserializer handle the basics.
Use #[serde(deny_unknown_fields)] when you want to catch typos in configuration keys. This prevents silent failures where a misspelled key is ignored and the field falls back to a default or causes a missing field error.
Use a validate method on your config struct when rules span multiple fields or involve business logic. Checking that host isn't empty or that max_connections is greater than zero requires looking at the value, not just the type.
Use #[serde(default = "fn")] when a field is optional and has a sensible default. This keeps the struct fields non-optional and simplifies the rest of the code. Document the default in the function.
Reach for the config crate when you need a hierarchy of sources. It merges defaults, files, environment variables, and CLI arguments into a single config object. It supports validation via its try_deserialize method and handles the complexity of layered configuration.