The date math that breaks your bot
You're writing a scheduler that posts a reminder every Monday at 9 AM. You grab the current time, add seven days, and set the hour. It works on your machine. Then you deploy it to a server in a different timezone, and the bot fires at 2 AM. Or you hit February 29th and your integer math overflows. Dates look like simple numbers until they aren't. Leap years, daylight saving time, and timezone offsets turn basic arithmetic into a minefield.
Rust's standard library gives you std::time::SystemTime, which is great for measuring elapsed time but terrible for human dates. It returns a duration since an unspecified point, and converting that to "October 25, 2024" requires platform-specific code. The chrono crate fills this gap. It provides a complete toolkit for dates and times, with types that enforce correctness and methods that handle the messy edge cases.
Naive dates versus aware datetimes
chrono splits time into two camps. NaiveDateTime holds a date and time without any context about where it happened. It's just a point on a timeline. DateTime<Tz> wraps that point with a timezone. It knows if it's UTC, New York, or Tokyo. You use Naive for calculations where the location doesn't matter, like "how many days between these two events". You use DateTime when the location matters, like "what time is it now" or "schedule this meeting".
Think of NaiveDateTime like a coordinate on a map without the country name. It tells you exactly where something is relative to a grid, but it doesn't tell you which rules apply. DateTime adds the country. It tells you the coordinate and the jurisdiction. Rust's type system enforces this split. You can't accidentally mix a UTC time with a Local time without an explicit conversion. This prevents a whole class of bugs where code assumes a timezone that isn't there.
Creating and manipulating naive dates
Start with NaiveDate and NaiveDateTime. These types live on the stack and are cheap to copy. They are perfect for parsing input, doing math, and storing values in a database where the timezone is implicit.
use chrono::{NaiveDate, NaiveDateTime, Datelike, Timelike};
/// Demonstrates creating naive dates and performing safe arithmetic.
fn main() {
// Create a date without timezone info.
// The _opt suffix returns Option, so we handle invalid dates safely.
let date = NaiveDate::from_ymd_opt(2024, 10, 25)
.expect("Invalid date");
// Combine date and time into a NaiveDateTime.
// We use from_hms_opt to avoid panicking on bad hours.
let naive_dt = NaiveDateTime::new(
date,
chrono::NaiveTime::from_hms_opt(14, 30, 0).expect("Invalid time")
);
// Add days safely. checked_add_days handles overflows and month boundaries.
let future = naive_dt
.checked_add_days(chrono::Duration::days(5))
.expect("Overflow");
// Format for display. %Y is year, %m is month, %d is day.
println!("Original: {}", naive_dt.format("%Y-%m-%d %H:%M"));
println!("Future: {}", future.format("%Y-%m-%d %H:%M"));
}
When you call from_ymd_opt, the crate validates the date. October 25th exists, so you get Some(NaiveDate). If you passed February 30th, you'd get None. The expect panics only if your code has a bug, not because of user input. checked_add_days takes a Duration. Duration::days(5) creates a span of 5 * 24 * 60 * 60 * 1,000,000,000 nanoseconds. The method adds this span to the timestamp. It returns Option because adding a duration could theoretically overflow the internal representation, though that's rare for human timescales. format uses a syntax similar to C's strftime. It doesn't allocate a new string; it returns a formatter object that prints directly.
Convention aside: always use the _opt constructors. The non-_opt versions like from_ymd exist for legacy code, but they panic on invalid input. The community standard is to use _opt and handle the Option or Result. Panics on invalid dates are bugs, not features.
Parsing and timezones in the wild
Real applications rarely create dates from literals. They parse strings from APIs, logs, or user input. chrono provides parsers for common formats and a flexible parser for custom strings.
use chrono::{DateTime, Utc, Local};
/// Parses an RFC3339 string and converts it to local time.
fn process_event(event_time_str: &str) -> Result<(), Box<dyn std::error::Error>> {
// Parse RFC3339 string directly into UTC.
// This is the standard format for APIs and logs.
let utc_dt: DateTime<Utc> = DateTime::parse_from_rfc3339(event_time_str)?;
// Convert UTC to the system's local timezone.
// with_timezone changes the view, not the instant in time.
let local_dt = utc_dt.with_timezone(&Local);
println!("Event in UTC: {}", utc_dt.format("%+"));
println!("Event Local: {}", local_dt.format("%+"));
Ok(())
}
parse_from_rfc3339 is the gold standard for parsing. RFC 3339 is the internet's lingua franca for timestamps. It handles leap seconds and timezone offsets correctly. The parser returns a DateTime<Utc> if the input ends with Z, or a DateTime<FixedOffset> if it has an offset like +05:00. You can convert any DateTime to Utc using with_timezone(&Utc). This is a zero-cost operation that just changes the wrapper. The underlying instant in time stays the same.
Convention aside: store everything in UTC. Convert to local time only when you show it to a human. Logs, databases, and APIs should use UTC. This eliminates ambiguity and makes it easy to correlate events across systems. If you store local time, you have to guess the timezone when you read it back, and that guess is often wrong.
Pitfalls and gotchas
chrono::Duration represents a fixed span of nanoseconds. It does not know about leap seconds or daylight saving time. If you add Duration::days(1) to a time during a DST transition, the result might be 23 hours or 25 hours away from the original clock time. For calendar arithmetic that respects DST, use NaiveDate math or checked_add_days on NaiveDateTime and then re-attach the timezone.
Another trap is mixing std::time::Duration and chrono::Duration. They are different types. std::time::Duration is for system calls and sleeps. chrono::Duration is for date math. You can convert between them, but the compiler won't do it automatically. If you pass a std::time::Duration to a chrono method, you'll get E0308 (mismatched types). Check your imports.
Trait imports are another common stumbling block. Methods like year(), month(), and hour() are not on the struct itself. They are on traits like Datelike and Timelike. You must import these traits to use the methods. If you see E0599 (no method named year found), you forgot use chrono::Datelike.
Check your imports. If year() is missing, you forgot use chrono::Datelike.
Choosing your date crate
chrono is the most popular date crate in Rust, but it's not the only option. The ecosystem has evolved, and newer crates address some of chrono's limitations.
Use chrono when you need a mature, widely-adopted crate with extensive community support and documentation. Use chrono for general-purpose date and time work where you value convenience and ecosystem compatibility over absolute performance. Use time when you need strict correctness, zero-cost abstractions, and a modern API design that separates concerns more aggressively. Use time for new projects where you want to avoid chrono's maintenance-mode status and potential edge-case bugs. Use jiff when you need high-performance parsing and formatting, especially for log processing or high-throughput servers. Use jiff if your workload involves parsing millions of timestamps per second and every nanosecond counts.
Pick the crate that matches your threat model. chrono is safe for most apps; time and jiff are for the edge.