Serializing a chrono::DateTime Using Serde

Have you ever found yourself in need of formatting dates created with chrono for your users? If so, I would like to share a solution I used at work recently.

In an attempt to build a report for my business line, I was fetching data from my Postgresql DB using sqlx which would end up in a tsv file. However, I had multiple reports that required the same data from the same query but they required different formatting for their dates.

We use chrono::DateTime<Utc> to map SQL's timestamptz type to Rust.

First, we have a table with an optional timestamptz field.

create table users (
    name varchar not null,
    updated_at timestamptz
)

We then have our corresponding struct with matching fields. In this case, the DateTime is optional.

pub struct User {
    name: String,
    #[serde(serialize_with = "serialize_dt")]
    updated_at: Option<chrono::DateTime<Utc>>,
}

Finally, we implement our custom serializer 'serialize_dt', which serde will use whenever serializing the updated_at field on User. But first, lets look at the requirements. Serde's documentation informs us of the following:

#[serde(serialize_with = "path")]

Serialize this field using a function that is different from its implementation of Serialize. The given function must be callable as fn<S>(&T, S) -> Result<S::Ok, S::Error> where S: Serializer, although it may also be generic over T. Fields used with serialize_with are not required to implement Serialize.

This is what the function signature will look like:

pub fn serialize_dt<S>(dt: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
 todo!()
}

Next, because our DateTime is wrapped in an Option, we use an 'if let Some()' to handle both its Some and None variants.

pub fn serialize_dt<S>(dt: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    if let Some(dt) = dt {
        todo!()
    } else {
        todo!()
    }

}

In case we do have a DateTime, we want to format its output. For that, we can call .format() on it, which expects a &str representing a valid escape sequence [See link for supported sequences]. The .format() method returns a DelayedFormat<StrftimeItems<'a>>, which doesn't implement the .serialize() method. To remedy that, we will simply turn it into a String using .to_string() chain call .serialize() and pass to it our serializer.

pub fn serialize_dt<S>(dt: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    if let Some(dt) = dt {
        dt.format("%m/%d/%Y %H:%M")
            .to_string()
            .serialize(serializer)
    } else {
        todo!()
    }
}

Finally, all we have to do is handle the None variant of our Option. Which is made simple by the Serializer implementation. We simply use the .serialize_none() method on serializer.

pub fn serialize_dt<S>(dt: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    if let Some(dt) = dt {
        dt.format("%m/%d/%Y %H:%M")
            .to_string()
            .serialize(serializer)
    } else {
        serializer.serialize_none()
    }
}

Here you go! We now have a human-readable date for our report, which we can apply to any field using Option<chrono::DateTime<Utc>>. We could have multiple variants for multiple formats, but by now you should be good to go to build your own. I hope this will have helped you come to a nice solution faster than me.

Cheers!