The missing handshake
You build a struct to track server events. You add a timestamp field using chrono::DateTime<Utc>. You run cargo run to dump it to JSON. The compiler stops you dead. It complains that DateTime does not implement Serialize or Deserialize. You double-check your imports. Everything looks correct. The problem is not your code. It is a missing handshake between two independent crates.
Serde is a serialization framework. It works by generating code that calls methods on your types. Those methods only exist if the type author wrote them. chrono is a date and time library. Its authors could have shipped serialization support by default, but they did not. They made it optional. This keeps the base crate small and avoids pulling in extra dependencies for users who only need local time calculations. You have to explicitly ask for the bridge. Think of it like a power adapter. Your device has the connector. The wall has the outlet. The adapter carries the current. Without the adapter, nothing happens. The serde feature flag is that adapter.
Enable the feature flag. The compiler will do the rest.
How the feature flag bridges the gap
Rust crates use feature flags to toggle optional functionality at compile time. When you add features = ["serde"] to your chrono dependency, you tell the compiler to include a hidden module inside chrono. That module contains the Serialize and Deserialize implementations for DateTime, NaiveDateTime, Duration, and other time types. Without the flag, those implementations are stripped out. The derive macro has nothing to call. The code fails to compile.
The chrono crate defaults to ISO 8601 strings for serialization. This is a community convention. Most APIs, databases, and logging systems expect timestamps in that format. You do not need to write custom formatters for standard use cases. The built-in implementation handles timezone offsets, leap seconds, and edge cases correctly. You only reach for custom serialization when you are forced to integrate with a legacy system that demands Unix timestamps or a non-standard layout.
Trust the derive macro. It generates exactly what you need, nothing more.
Minimal working example
Start by updating your dependency configuration. The serde feature must be enabled on chrono, not just on serde itself.
[dependencies]
# Enable the serde feature to unlock serialization traits
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Next, define your struct and attach the derive macros. The macros will generate the trait implementations automatically.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Represents a scheduled event with a precise UTC timestamp
#[derive(Serialize, Deserialize, Debug)]
struct Event {
name: String,
timestamp: DateTime<Utc>,
}
fn main() {
// Create an event with the current UTC time
let event = Event {
name: "System launch".to_string(),
timestamp: Utc::now(),
};
// Serialize to a JSON string
let json = serde_json::to_string(&event).unwrap();
println!("Serialized: {json}");
// Deserialize back into the struct
let parsed: Event = serde_json::from_str(&json).unwrap();
println!("Deserialized: {parsed:?}");
}
The output will look like this:
Serialized: {"name":"System launch","timestamp":"2024-06-15T14:32:10.123456789+00:00"}
The timestamp includes the date, time, nanosecond precision, and the +00:00 UTC offset. Deserialization reverses the process. The parser reads the string, validates the format, reconstructs the DateTime<Utc> value, and hands it back to your struct.
Keep your time types consistent. Mixing naive and aware dates in the same struct invites parsing failures.
What happens under the hood
When you write #[derive(Serialize, Deserialize)], the macro expands your struct into two trait implementations. It generates a serialize method that iterates over your fields. When it reaches timestamp, it calls timestamp.serialize(serializer). That method lives inside chrono, but it is hidden behind a #[cfg(feature = "serde")] gate. Enable the feature, and the compiler unlocks the implementation.
The generated code does not use reflection. It does not inspect types at runtime. It writes direct function calls. The compiler inlines the serialization logic. The result is zero-cost abstraction. You get the convenience of automatic serialization with the performance of hand-written code.
Deserialization works in reverse. The macro generates a deserialize method that reads the JSON token stream. It expects a string for the timestamp field. It passes that string to chrono's from_str implementation. If the string matches ISO 8601, the parser succeeds. If it contains an invalid timezone or malformed digits, the parser returns an error. The error propagates up to your unwrap() or match statement. You handle it like any other I/O failure.
Read the trait bound error. It tells you exactly which crate is missing the implementation.
Realistic payload handling
Production code rarely deals with a single timestamp. You will encounter nested structures, optional fields, and collections. Serde handles them all without extra configuration.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Tracks a user session with creation and expiration times
#[derive(Serialize, Deserialize, Debug)]
struct Session {
user_id: u64,
created_at: DateTime<Utc>,
expires_at: Option<DateTime<Utc>>,
tags: Vec<String>,
}
fn main() {
let session = Session {
user_id: 42,
created_at: Utc::now(),
expires_at: None,
tags: vec!["admin".to_string(), "active".to_string()],
};
// Serialize to pretty-printed JSON for logging
let json = serde_json::to_string_pretty(&session).unwrap();
println!("{json}");
}
The Option<DateTime<Utc>> field serializes to null when absent. Serde automatically handles the Option wrapper. You do not need to write custom serializers for optional timestamps. The Vec<String> field serializes to a JSON array. Everything flows through the same derive pipeline.
Convention aside: the Rust community expects chrono timestamps to serialize as ISO 8601 strings. If you see a crate using Unix epoch integers for timestamps, it is either using the time crate or applying a custom #[serde(with = "...")] attribute. Stick to the default string format unless you have a documented reason to deviate.
Keep your serialization format predictable. Future maintainers will thank you.
Common traps and compiler signals
The most frequent mistake is forgetting the feature flag. You import chrono, you derive Serialize, and the compiler rejects you with E0277 (the trait bound DateTime<Utc>: Serialize is not satisfied). The error message points to your struct field. It tells you that DateTime<Utc> does not implement Serialize. The fix is always the same. Add features = ["serde"] to your Cargo.toml. Clean your build directory if the error persists. Cargo sometimes caches feature resolution.
Another trap is mixing time libraries. chrono and time are both popular. They serialize differently. time often defaults to Unix timestamps or strict RFC 3339 layouts. If you deserialize a time timestamp into a chrono field, the parser fails. Pick one library for your project. Do not swap them mid-development.
Timezone confusion causes silent data corruption. DateTime<Utc> serializes with a Z or +00:00 suffix. DateTime<Local> serializes with your system offset. If your database expects naive strings without timezone information, the extra characters break your queries. Strip the offset before saving, or store the raw string and parse it on read. Do not assume the database will ignore the suffix.
Deserialization panics when the JSON shape changes. If a frontend stops sending the timestamp field, serde_json::from_str returns an error. You must handle it. Use Result propagation. Log the malformed payload. Do not unwrap in production code.
Treat deserialization errors as data validation failures. Handle them gracefully.
Choosing your serialization strategy
Use chrono with the serde feature when you need full timezone awareness and want ISO 8601 strings out of the box. Use the time crate when your project already depends on it and you prefer Unix timestamps or strict RFC 3339 parsing. Use #[serde(with = "chrono::serde::ts_seconds")] when your API requires Unix epoch integers instead of readable strings. Use manual #[serde(serialize_with = "...")] when you need a custom format that breaks from standard conventions. Reach for plain strings when you are just passing timestamps through a proxy and do not need to parse them in Rust.
Pick one time library and stick with it. Switching mid-project fractures your serialization logic.