Skip to content
Select theme

How to parse and format dates with chrono

Short answer: parse with DateTime::parse_from_rfc3339, format with the .format("...") method and its %-specifiers:

[dependencies]
chrono = "0.4"
src/main.rs
use chrono::DateTime;
fn main() {
let raw = "2026-10-28T14:30:00+08:00";
let dt = DateTime::parse_from_rfc3339(raw).expect("invalid timestamp");
println!("{dt}");
println!("{}", dt.format("%Y-%m-%d %H:%M:%S %z"));
}
Terminal window
cargo run -q
2026-10-28 14:30:00 +08:00
2026-10-28 14:30:00 +0800

The two lines are the same instant in two spellings: {dt} prints the Display form (offset with a colon), the %z specifier prints the compact one. From a fresh project — cargo new date-demo, paste the dependency into Cargo.toml, then replace src/main.rs with each block below.

The type is DateTime<FixedOffset>, not a string

Section titled “The type is DateTime<FixedOffset>, not a string”

parse_from_rfc3339 returns a DateTime<FixedOffset>: a point in time plus the UTC offset that came out of the input (+08:00 here). That offset is data, not decoration — it is what makes the value comparable with any other DateTime and convertible back with to_rfc3339(). The standard library has nothing like this: SystemTime from the previous article is an opaque instant with no calendar, and a calendar with timezones is exactly what you are paying the dependency for.

One caveat on .format(...): it does not return a String. It returns a borrowing view that implements Display — perfect for println!, but if you want to own it, call .to_string() on the result.

Not every timestamp string carries an offset. NaiveDateTime::parse_from_str takes a format string and parses exactly what you give it — no more:

src/main.rs
use chrono::{FixedOffset, NaiveDateTime, TimeZone};
fn main() {
let naive = NaiveDateTime::parse_from_str("2026-10-28 14:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
let shanghai = FixedOffset::east_opt(8 * 3600).unwrap().from_local_datetime(&naive).unwrap();
let utc = FixedOffset::east_opt(0).unwrap().from_local_datetime(&naive).unwrap();
println!("{naive}");
println!("{}", shanghai.to_rfc3339());
println!("{}", utc.to_rfc3339());
println!("same instant: {}", shanghai == utc);
}
2026-10-28 14:30:00
2026-10-28T14:30:00+08:00
2026-10-28T14:30:00+00:00
same instant: false

Naive means what it says: this value knows the wall-clock digits and nothing else. It does not know where it happened, so it does not name a real instant — the same string describes 8 o’clock in Shanghai and 8 o’clock in UTC, and both parse to an equal NaiveDateTime that means two moments eight hours apart. The trap is that nothing warns you at parse time; it surfaces later, when two systems compare timestamps and disagree. from_local_datetime is the moment you commit to an answer: attach the offset you know the string was written in, and from then on every value is a real point in time.

.format speaks chrono’s own dialect — mostly strftime-compatible, and completely separate from the {} mini-language that println! and format! use for numbers and alignment:

src/main.rs
use chrono::DateTime;
fn main() {
let dt = DateTime::parse_from_rfc3339("2026-10-28T14:30:00+08:00").unwrap();
println!("{}", dt.format("%Y-%m-%d"));
println!("{}", dt.format("%H:%M:%S"));
println!("{}", dt.format("%A, %d %B %Y"));
println!("{}", dt.format("%s"));
println!("{}", dt.to_rfc2822());
}
2026-10-28
14:30:00
Wednesday, 28 October 2026
1793169000
Wed, 28 Oct 2026 14:30:00 +0800

%s is the Unix timestamp — the same number SystemTime gives you, recovered from the parsed date. to_rfc2822() and to_rfc3339() are ready-made formats for email headers and APIs, so the common cases never need a specifier at all. The same specifiers work in both directions: parse_from_str takes them on the way in, format on the way out.

Both parse functions return a Result, and mismatched input is a normal error, not a panic:

src/main.rs
use chrono::DateTime;
fn main() {
match DateTime::parse_from_rfc3339("2026-10-28 14:30:00") {
Ok(dt) => println!("{dt}"),
Err(e) => println!("rejected: {e}"),
}
}
rejected: premature end of input

That space is the whole story: RFC 3339 wants T between the date and the time, so the parser stopped where the input stopped matching and reports what it ran out of. If the string came from a database or a log line with a space in the middle, either normalize it to T or switch to NaiveDateTime::parse_from_str with a format that matches it — and then deal with the offset deliberately, as above.

  • The previous article covered SystemTime — the Unix timestamp %s recovers above, and the standard library’s only clock.
  • Attaching Local time and handling DST is its own article later in this chapter.
  • Working with dates and time — the rest of the chapter.