The date problem in Rust
You are building a task tracker. You save a deadline to a JSON file. Later, you load the file and realize the date is just a string like "2024-05-20". You have to write parsing code. You have to handle timezones. You have to worry about leap years and daylight saving shifts. Rust makes you think about all this, but you do not want to reinvent the calendar.
Serde does not know what a date is. Serde knows strings, numbers, booleans, and arrays. It turns Rust structs into JSON and back. When it hits a date field, it stops. It needs help.
That help comes from the chrono crate. chrono is the standard library for time in Rust. It understands calendars, timezones, and durations. When you enable the serde feature in chrono, it teaches Serde how to talk to dates. The result is automatic serialization to ISO 8601 strings and reliable deserialization back to typed values.
The bridge between serde and time
Serde is a translator that speaks JSON. chrono speaks time. They need a dictionary to communicate. The serde feature in chrono provides that dictionary.
Without the feature, chrono types are just opaque structs to Serde. Serde sees a DateTime<Utc> and has no idea how to turn it into a JSON string. It rejects the code at compile time.
With the feature, chrono implements the Serialize and Deserialize traits for its types. It formats dates as ISO 8601 strings when writing to JSON. It parses ISO 8601 strings when reading from JSON. ISO 8601 is the international standard for date-time representation. It looks like "2024-05-20T12:00:00Z". The T separates date and time. The Z marks UTC.
This format is human-readable, sortable, and unambiguous. It survives the internet. Every major API and database supports it. You rarely need to fight it.
Minimal example
Add chrono with the serde feature to your Cargo.toml. The feature flag is required. Without it, the traits do not exist.
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Define a struct with a DateTime<Utc> field. Derive Serialize and Deserialize. The Utc type anchors the time to Coordinated Universal Time. This is the safe default for storage.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// A simple event with a timestamp.
#[derive(Serialize, Deserialize, Debug)]
struct Event {
/// When the event happened, always in UTC.
timestamp: DateTime<Utc>,
}
fn main() {
// Create an event with the current time.
let event = Event {
timestamp: Utc::now(),
};
// Serialize to a JSON string.
// chrono formats as ISO 8601 automatically.
let json = serde_json::to_string(&event).unwrap();
println!("JSON: {}", json);
// Deserialize back to the struct.
// chrono parses the ISO 8601 string.
let loaded: Event = serde_json::from_str(&json).unwrap();
println!("Loaded: {:?}", loaded);
}
Run this and watch the output. You will see a string like {"timestamp":"2024-05-20T14:32:10.123456789Z"}. The precision includes nanoseconds. The Z confirms UTC. Deserialization recovers the exact moment.
Trust the ISO standard. It survives the internet.
What happens under the hood
When you call serde_json::to_string, Serde walks the struct. It finds the timestamp field. It calls Serialize::serialize on the DateTime<Utc>.
The chrono implementation formats the date-time into an ISO 8601 string. It writes that string to the JSON output. No manual formatting code is needed.
When you call serde_json::from_str, Serde parses the JSON. It finds the string value. It calls Deserialize::deserialize on the DateTime<Utc>.
The chrono implementation parses the string. It extracts the year, month, day, hour, minute, second, and offset. It normalizes everything to UTC. If the JSON contains an offset like +05:00, chrono adjusts the time and stores the UTC equivalent. You do not lose the time. You just normalize the representation.
This normalization is a key benefit. Your JSON can contain local times with offsets. Your Rust code always works with UTC. The conversion happens automatically.
Realistic example with validation
Real data is messy. JSON might be malformed. Dates might be out of order. Serde handles format errors, but it does not validate business logic. You need to check ranges and constraints after deserialization.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// A scheduled meeting with start and end times.
#[derive(Serialize, Deserialize, Debug)]
struct Meeting {
title: String,
start: DateTime<Utc>,
end: DateTime<Utc>,
}
/// Validates that the meeting makes sense.
fn validate_meeting(m: &Meeting) -> Result<(), String> {
if m.end <= m.start {
return Err("End time must be after start time".to_string());
}
Ok(())
}
fn main() {
let good_json = r#"{"title": "Sync", "start": "2024-06-01T10:00:00Z", "end": "2024-06-01T11:00:00Z"}"#;
let bad_json = r#"{"title": "Sync", "start": "yesterday", "end": "2024-06-01T11:00:00Z"}"#;
let logic_error_json = r#"{"title": "Sync", "start": "2024-06-01T11:00:00Z", "end": "2024-06-01T10:00:00Z"}"#;
// Parse good JSON.
match serde_json::from_str::<Meeting>(good_json) {
Ok(m) => {
if validate_meeting(&m).is_ok() {
println!("Valid meeting: {:?}", m);
}
}
Err(e) => eprintln!("Parse error: {}", e),
}
// Parse bad JSON.
match serde_json::from_str::<Meeting>(bad_json) {
Ok(_) => println!("Should not happen"),
Err(e) => eprintln!("Expected parse error: {}", e),
}
// Parse JSON with logic error.
match serde_json::from_str::<Meeting>(logic_error_json) {
Ok(m) => {
if let Err(e) = validate_meeting(&m) {
eprintln!("Logic error: {}", e);
}
}
Err(e) => eprintln!("Parse error: {}", e),
}
}
Serde catches the "yesterday" string and returns an error. The logic error passes Serde but fails validation. Separate format errors from business errors. Handle them with Result types.
Parse errors are data errors. Handle them with Result, not panics.
Pitfalls and compiler errors
Missing the feature flag
If you forget the serde feature in Cargo.toml, the compiler rejects you with E0277 (trait bound not satisfied). The error says DateTime<Utc> does not implement Serialize.
The fix is simple. Add features = ["serde"] to the chrono dependency. Do not try to implement the traits yourself. chrono provides a robust implementation.
Using #[serde(default)] on DateTime
You might want a field to default to the current time if it is missing from the JSON. The #[serde(default)] attribute requires the type to implement Default. DateTime<Utc> does not implement Default. There is no universal "default" time.
If you use #[serde(default)], the compiler rejects you with E0277 (trait bound not satisfied) for Default.
Provide a function instead. The function returns the default value.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
fn default_timestamp() -> DateTime<Utc> {
Utc::now()
}
#[derive(Serialize, Deserialize, Debug)]
struct Log {
message: String,
#[serde(default = "default_timestamp")]
timestamp: DateTime<Utc>,
}
This compiles and works. The function runs only when the field is missing.
NaiveDateTime vs DateTime
chrono offers NaiveDateTime for dates without timezones. It is tempting to use it because it is simpler. It is also dangerous.
A naive date has no anchor. "2024-03-10T02:30:00" could be in New York, London, or Tokyo. The moment in time changes based on the timezone. If you store naive dates, you lose information.
Use NaiveDateTime only when the timezone is irrelevant. A birthday is a naive date. It is the same calendar day everywhere. A recurring alarm at 8 AM is naive. It fires at 8 AM local time regardless of timezone.
For timestamps, events, and logs, use DateTime<Utc>. Store UTC. Convert to local time only for display.
Storing local time in JSON is a bug waiting to happen. Store UTC.
Custom formats
Serde defaults to ISO 8601. Some APIs require Unix timestamps. Some legacy systems use custom strings. You can override the format with the #[serde(with = "...")] attribute.
chrono provides helpers for common formats.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
struct LegacyLog {
message: String,
// Serialize as Unix timestamp in seconds.
#[serde(with = "chrono::serde::ts_seconds")]
timestamp: DateTime<Utc>,
}
This produces {"message":"Hello","timestamp":1716225600}. Deserialization reads the number and converts it back to DateTime<Utc>.
Use ts_milliseconds for millisecond precision. Use ts_nanos for nanoseconds. Use ts_seconds_option for optional timestamps.
Don't fight the format. Reach for #[serde(with = ...)].
Decision matrix
Use DateTime<Utc> when you store timestamps in a database, API, or file. It anchors the moment to a global standard and avoids timezone confusion.
Use NaiveDate when you need a calendar date without time or timezone, like a birthday or anniversary. It represents a day on the calendar, not a moment in time.
Use NaiveDateTime when you only care about the clock time without any timezone context, like a recurring alarm that fires at 8 AM regardless of where you are.
Use #[serde(with = "chrono::serde::ts_seconds")] when your external format requires a Unix timestamp instead of an ISO 8601 string.
Use #[serde(default = "fn")] when you need a default value for a missing date field. DateTime<Utc> does not implement Default, so you must provide a function.
Use chrono over the time crate when you want the most widely adopted ecosystem support. chrono is the de facto standard for date-time in Rust. Most libraries integrate with it.
Pick the type that matches your data's reality. UTC for moments, Naive for schedules, Unix for legacy.