When strings don't match the clock
You are processing a CSV export from a legacy system. Column A contains dates like 2023-10-05. Column B has dates like 10/05/2023. Column C stores Unix timestamps as 1696523400. You need to sort the rows by time. You cannot sort strings. The string "10/05/2023" sorts before "09/15/2023" because "1" comes before "0" in ASCII, but October is after September. You need to convert these strings into a type that understands calendar arithmetic, time zones, and duration.
Rust's standard library does not provide a built-in parser for human-readable dates. It gives you std::time::SystemTime and std::time::Duration, which are precise measurements of time but lack calendar awareness. To handle formats, you reach for a crate. The ecosystem standard is chrono. For numbers, the standard library provides robust formatting and parsing tools via std::fmt and str::parse.
The standard library's philosophy
Rust treats time as a measurement first and a representation second. SystemTime represents a point on the timeline relative to an unspecified epoch. Duration represents a span of time. These types are fast, precise, and safe. They do not know about months, leap years, time zones, or whether you prefer slashes or dashes.
This design is intentional. Calendars are political and change. Leap seconds are inserted irregularly. Daylight saving time rules vary by region and shift over decades. Calendar reforms have happened throughout history. By keeping std::time minimal, Rust avoids baking in assumptions that break across borders or eras. The standard library provides the primitives; crates provide the calendar logic.
Think of SystemTime like a GPS coordinate. It tells you exactly where you are relative to a fixed point. It does not tell you the street name, the city, or the zip code. Those are formats. To get the street name, you need a map service. In Rust, chrono is the map service. It translates between raw time measurements and human-readable addresses.
Minimal example: Parsing and formatting
Add chrono = "0.4" to your Cargo.toml. The chrono crate provides NaiveDate, NaiveDateTime, and DateTime types. Naive types have no time zone. They represent a calendar date or time without context. This is useful for parsing because you can validate the structure before deciding on a zone.
use chrono::{NaiveDate, Datelike};
fn main() {
// The input string uses a common ISO-like format.
let input = "2023-10-05";
// parse_from_str takes the string and a format specifier.
// It returns a Result because parsing can fail.
let date = NaiveDate::parse_from_str(input, "%Y-%m-%d").unwrap();
// Datelike provides access to year, month, and day components.
println!("Year: {}, Month: {}, Day: {}", date.year(), date.month(), date.day());
// format creates a formatter that implements Display.
// You can pass it to println! or call .to_string() to allocate.
let formatted = date.format("%d/%m/%Y");
println!("Reformatted: {}", formatted);
}
The format string uses specifiers similar to C's strftime. %Y matches a four-digit year. %m matches a two-digit month. %d matches a two-digit day. The parser validates the string against this pattern. If the string is "2023-13-05", parsing fails because month 13 does not exist. If the string is "2023-1-5", parsing fails because %m requires two digits. The format string is a contract. You define the shape, and chrono checks the data.
Convention aside: chrono is the de facto standard for date and time in Rust. The time crate is a modern alternative with better ergonomics, but chrono has broader ecosystem integration. Most third-party crates expect chrono types.
Naive versus aware: The timezone wall
chrono distinguishes between naive types and aware types. NaiveDate and NaiveDateTime have no time zone. DateTime<Utc> and DateTime<Local> carry timezone information. This split is enforced by the type system. You cannot add a NaiveDate to a DateTime. You cannot compare a naive time with an aware time.
This protection prevents silent timezone bugs. If you parse a date without a zone and assume it is UTC, you might be wrong. The compiler forces you to make an explicit choice. You must attach a timezone to a naive type before you can use it with aware types.
use chrono::{NaiveDateTime, DateTime, Utc};
fn attach_utc(dt: NaiveDateTime) -> DateTime<Utc> {
// and_utc converts the naive datetime to UTC.
// This is a zero-cost operation; it just wraps the value.
dt.and_utc()
}
If you try to mix types, the compiler rejects you with E0277 (trait bound not satisfied) or E0308 (mismatched types). The error message will point out that NaiveDateTime is not the same as DateTime<Utc>. This is a feature. It stops you from accidentally treating a local time as UTC.
Trust the naive/aware split. It saves you from debugging timezone offsets in production.
Number formatting: Beyond integers
Rust's standard library handles number formatting through the format! macro and formatting traits. Display is the default trait for user-facing output. Debug is for developer diagnostics. Specialized traits like Binary, Octal, and LowerHex provide base conversions.
fn main() {
let value = 255;
// Display uses the standard decimal representation.
println!("Decimal: {}", value);
// Binary, Octal, and LowerHex convert to other bases.
println!("Binary: {:b}", value);
println!("Octal: {:o}", value);
println!("Hex: {:x}", value);
// You can pad and align numbers.
// {:0>8b} pads with zeros to width 8, right-aligned.
println!("Padded binary: {:0>8b}", value);
// Floats support precision control.
let pi = 3.1415926535;
println!("Pi rounded: {:.2}", pi);
}
The format string is resolved at compile time. The macro generates code that calls the appropriate trait method. There is no runtime parsing of the format string. This makes formatting fast and safe. If you use an invalid specifier, the compiler catches it.
For parsing numbers, use str::parse. This method works on any type that implements FromStr. Integers and floats implement FromStr.
fn main() {
// parse infers the type from the context.
let num: i32 = "42".parse().unwrap();
// from_str_radix parses integers in arbitrary bases.
let hex = i32::from_str_radix("ff", 16).unwrap();
println!("Hex ff is decimal {}", hex);
}
If the string is not a valid number, parse returns an error. Handle the error in production code. unwrap() is only for examples.
Realistic example: Parsing mixed logs
Real-world data often contains mixed formats. A log parser might encounter ISO 8601 timestamps, US-style dates, and Unix epochs. You can write a function that tries multiple formats and returns a unified type.
use chrono::{NaiveDateTime, DateTime, Utc};
#[derive(Debug)]
enum LogParseError {
InvalidFormat(String),
Chrono(chrono::ParseError),
}
/// Attempts to parse a timestamp from multiple common formats.
/// Returns a DateTime<Utc> or an error describing the failure.
fn parse_log_timestamp(input: &str) -> Result<DateTime<Utc>, LogParseError> {
// Try ISO 8601 first, as it is the most common in logs.
// %.f handles fractional seconds if present.
if let Ok(dt) = NaiveDateTime::parse_from_str(input, "%Y-%m-%dT%H:%M:%S%.fZ") {
return Ok(dt.and_utc());
}
// Fall back to a US-style date with time.
// This format lacks a timezone, so we assume UTC for this example.
if let Ok(dt) = NaiveDateTime::parse_from_str(input, "%m/%d/%Y %H:%M:%S") {
return Ok(dt.and_utc());
}
// If neither matches, return a custom error.
Err(LogParseError::InvalidFormat(input.to_string()))
}
This function returns a Result. The caller must handle the error. If you pass "2023-13-05", the first parser fails because month 13 is invalid. The second parser fails because the format does not match. The function returns InvalidFormat. This allows the caller to log the bad data or skip it.
Convention aside: Store timestamps in UTC in your database and internal state. Convert to local time only at the UI layer. This avoids DST bugs and makes sorting and comparison reliable.
Pitfalls and compiler errors
Parsing external data requires careful error handling. parse_from_str returns a Result. If you call unwrap() on bad input, your program panics. In production, use ? to propagate errors or handle them gracefully.
The format method returns a DelayedFormat struct, not a String. DelayedFormat borrows the date and implements Display. It does not allocate memory until you print it or call .to_string(). This is a performance optimization. It prevents unnecessary allocation in hot loops where you just want to print.
If you try to return a DelayedFormat from a function, the compiler rejects you. The formatter borrows the date, so it cannot outlive the date. You get E0515 (cannot return value referencing local variable) or a similar lifetime error. You must call .to_string() to allocate a owned string.
use chrono::NaiveDate;
/// This function demonstrates the lifetime trap.
/// It compiles because we call .to_string() to allocate.
fn get_date_string() -> String {
let date = NaiveDate::from_ymd_opt(2023, 10, 5).unwrap();
// format returns a formatter that borrows date.
// .to_string() consumes the formatter and allocates a String.
date.format("%Y-%m-%d").to_string()
}
Another pitfall is locale-aware formatting. chrono does not provide built-in support for locale-specific date formats. The format specifiers are based on C's strftime, which uses the C locale. If you need to display dates in different languages or regional formats, you need a separate crate like chrono-humanize or a localization library.
Decision: Choosing your tools
Use chrono when you need a mature ecosystem with broad support for time zones, durations, and calendar arithmetic. Most Rust crates expect chrono types, so it reduces integration friction.
Use the time crate when you prefer a modern API with better ergonomics and are willing to accept slightly less third-party integration. time offers a cleaner design and fewer edge cases, but you may need adapters for older libraries.
Use std::time::SystemTime when you only need to measure elapsed time or interact with the OS clock and do not need calendar dates. SystemTime is fast and has no dependencies.
Use std::fmt and str::parse when you are formatting or parsing standard numeric types like integers and floats without calendar complexity. The standard library handles these cases efficiently.
Use num_format when you need to add separators like commas to large numbers for display. The standard library does not support grouping separators.
Don't fight the compiler here. If you get a type mismatch between naive and aware types, the borrow checker is protecting you from a timezone bug. Attach the timezone explicitly.