How to Serialize and Deserialize Dates with Serde in Rust

Serialize and deserialize Rust dates with Serde by using the chrono crate and specific attribute annotations like ts_utc or iso8601.

When Serde hits a date

You are building an API that tracks user events. You define a struct with a timestamp field using chrono::DateTime. You derive Serialize and Deserialize, run the code, and the compiler rejects you. Dates are not simple values to the compiler. They carry timezone information, calendar systems, and formatting rules. Serde refuses to guess how you want a date to appear on the wire. It will not serialize a DateTime until you explicitly tell it which format to use.

This is not a bug. It is a safety feature. If Serde silently converted dates to strings, your API might send "2023-10-27" one day and 1698400800 the next, depending on which crate version you pulled in. The compiler forces you to make the contract explicit.

The translator needs a dictionary

Serde acts as a translator between Rust types and data formats like JSON or MessagePack. For integers, strings, and booleans, the translation is obvious. For complex types, Serde needs a dictionary entry. A DateTime<Utc> could become a Unix timestamp integer, an ISO 8601 string, a local date string, or a structured object with separate year, month, and day fields.

The chrono crate provides these dictionary entries through its chrono::serde module. You point Serde to the right entry using the #[serde(with = "...")] attribute. This attribute swaps out the default serializer for a custom one provided by chrono. You choose the format that matches your API contract, and Serde enforces it at compile time.

Convention: The Rust community treats chrono::serde as the standard way to handle dates. You rarely write custom serializers for dates unless you need a format that chrono does not support. Reach for the built-in modules first.

Minimal example: timestamps

The most common use case is serializing a UTC timestamp as an integer. This is compact and unambiguous. Use chrono::serde::ts_seconds for second precision or ts_millis for milliseconds.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

/// An event with a UTC timestamp.
#[derive(Debug, Serialize, Deserialize)]
struct Event {
    /// The event ID.
    id: u64,
    /// Serialized as a Unix timestamp in seconds.
    #[serde(with = "chrono::serde::ts_seconds")]
    timestamp: DateTime<Utc>,
}

fn main() {
    let event = Event {
        id: 1,
        timestamp: Utc::now(),
    };

    // Serializes to: {"id":1,"timestamp":1698400800}
    let json = serde_json::to_string(&event).unwrap();
    println!("{}", json);
}

The with attribute takes a path to a module that implements serialize and deserialize functions. chrono::serde::ts_seconds provides exactly that. The serializer extracts the timestamp as an integer. The deserializer takes an integer and constructs a DateTime<Utc>.

Convention: Use chrono::serde::ts_seconds for general-purpose APIs. Millisecond precision is rarely needed for user-facing events and wastes bandwidth. Reserve ts_millis for high-frequency telemetry or financial data where sub-second accuracy matters.

The feature flag trap

This code will not compile unless you enable the serde feature in chrono. The chrono crate is modular. It splits functionality into features to keep compile times low and support no_std environments. The chrono::serde module only exists when the feature is active.

Add this to your Cargo.toml:

[dependencies]
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

If you forget the feature, the compiler rejects the code with E0433 (unresolved import). The module chrono::serde simply does not exist in the binary. This is a common stumbling block for beginners. The error message points to the import, not the Cargo.toml. Check your features when the module is missing.

Trust the error message. If the module is unresolved, the feature is likely disabled.

Walkthrough: how the with attribute works

When you derive Serialize, Serde generates code that calls serialize on each field. The with attribute intercepts this process. Instead of calling the default serializer for DateTime, the generated code calls the serialize function inside the module you specified.

At runtime, the flow looks like this:

  1. Serde calls chrono::serde::ts_seconds::serialize(&event.timestamp, serializer).
  2. The chrono function extracts the timestamp as an i64.
  3. It delegates to the serializer's serialize_i64 method.
  4. The JSON writer emits the integer.

Deserialization reverses the process. Serde calls chrono::serde::ts_seconds::deserialize(deserializer). The function reads a value from the deserializer. If the value is an integer, it converts it to a DateTime<Utc>. If the value is a string, the deserializer fails.

This strict typing prevents silent data corruption. If your JSON contains a string but your code expects a timestamp integer, deserialization fails immediately. You get a clear error instead of a corrupted date.

Convention: Keep the with attribute on the field, not the struct. Field-level attributes make the format explicit for each date. If you apply it to the struct, you risk applying the wrong format to unrelated fields.

Realistic example: mixed formats

Real applications often need multiple date formats. A public API might require ISO 8601 strings for readability, while an internal log uses compact timestamps. You can mix and match serializers on different fields.

use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};

/// A report with mixed date formats.
#[derive(Debug, Serialize, Deserialize)]
struct Report {
    /// ISO 8601 string for human readability.
    #[serde(with = "chrono::serde::ts_utc")]
    created_at: DateTime<Utc>,

    /// Just the calendar date, no time.
    #[serde(with = "chrono::serde::date")]
    event_date: NaiveDate,

    /// Compact timestamp for internal storage.
    #[serde(with = "chrono::serde::ts_millis")]
    processed_at: DateTime<Utc>,
}

fn main() {
    let report = Report {
        created_at: Utc::now(),
        event_date: NaiveDate::from_ymd_opt(2023, 10, 27).unwrap(),
        processed_at: Utc::now(),
    };

    // Serializes to:
    // {
    //   "created_at": "2023-10-27T10:00:00Z",
    //   "event_date": "2023-10-27",
    //   "processed_at": 1698400800000
    // }
    let json = serde_json::to_string_pretty(&report).unwrap();
    println!("{}", json);
}

The ts_utc serializer produces an ISO 8601 string with a Z suffix, indicating UTC. The date serializer works with NaiveDate, which represents a date without timezone information. This is useful for birthdays or anniversaries where the time of day does not matter.

Convention: Use NaiveDate for calendar dates like birthdays. Use DateTime<Utc> for events that happened at a specific moment. Mixing these types in your struct makes the intent clear to other developers.

Advanced: custom formats with serde_with

The chrono::serde module covers most standard formats. If you need a custom format like "YYYY-MM-DD" without time, or a localized string, use the serde_with crate. It provides a more flexible attribute system.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, Timestamp};

#[serde_as]
#[derive(Debug, Serialize, Deserialize)]
struct CustomEvent {
    /// Custom format: seconds since epoch, but stored as a string in JSON.
    #[serde_as(as = "Timestamp<Seconds>")]
    timestamp: DateTime<Utc>,
}

The serde_as attribute enables serde_with macros. The Timestamp<Seconds> type tells serde_with to serialize the timestamp as seconds, but it allows more control over the output type. You can combine this with other serde_with adapters to handle edge cases.

Convention: Stick to chrono::serde for standard formats. Only reach for serde_with when you need custom behavior. Adding serde_with increases compile time and dependency complexity. Use it sparingly.

Pitfalls and errors

Dates introduce several failure modes. Understanding these helps you debug faster.

If you omit the with attribute, the compiler rejects the code with E0277 (the trait Serialize is not implemented for DateTime<Utc>). This error is clear. Add the attribute to fix it.

If you use the wrong serializer for the data, deserialization fails at runtime. For example, if you use ts_seconds but the JSON contains an ISO string, chrono returns a parse error. The error message points to the field and the expected format. Check your wire format against your serializer.

Timezone confusion is a silent killer. If you serialize a DateTime<Local> with ts_utc, the result depends on the local timezone. The timestamp might be correct, but the string representation could be misleading. Always use DateTime<Utc> for serialization unless you have a specific reason to preserve local time.

Convention: Store and serialize dates as UTC. Convert to local time only at the display layer. This prevents timezone drift and makes debugging easier. If you see a date that looks wrong, check the timezone first.

Decision: picking your serializer

Use chrono::serde::ts_seconds when you need compact integer timestamps and your system handles UTC internally. Use chrono::serde::ts_millis when you need millisecond precision for high-frequency data or financial records. Use chrono::serde::ts_utc when your API contract requires ISO 8601 strings with explicit UTC indicators. Use chrono::serde::date when you only care about the calendar date and want to strip time information. Use serde_with when you need custom formats that chrono does not provide out of the box, such as localized strings or non-standard layouts. Reach for NaiveDate when the time of day is irrelevant, like birthdays or holidays. Reach for DateTime<Utc> when the exact moment matters, like event logs or transactions.

Match the serializer to the wire format your API expects. Inconsistent formats break clients. Pick one and enforce it across your codebase.

Where to go next