When strings fail you
You are building a background job scheduler. It needs to record exactly when a task started, compare that moment against a user deadline, and print a readable log line. You reach for a string. You format it, parse it, and immediately hit a wall. String comparison breaks on leap years. Timezone offsets corrupt your math. Manual parsing leaves your code vulnerable to malformed input. Time handling demands a dedicated type system.
The coordinate system for time
The time crate replaces fragile string juggling with explicit types. At its core sits OffsetDateTime. This type stores a precise moment on the timeline alongside a fixed offset from UTC. Think of it as a coordinate. The timestamp tells you where you are on the global clock. The offset tells you how to translate that coordinate for a human reading it in Tokyo or Toronto.
Rust also provides PrimitiveDateTime. This type holds a date and time without any timezone information. It is useful for calendar events that repeat every day regardless of location, or for database columns that strip offsets to save space. The crate forces you to choose between these two explicitly. You cannot accidentally mix a naive date with a timezone-aware one. The compiler will reject the mismatch before your program runs.
Minimal working example
Add the dependency to your Cargo.toml. Enable the features you actually need. The macros feature is the most important one. It moves format validation from runtime to compile time.
[dependencies]
time = { version = "0.3", features = ["formatting", "parsing", "macros"] }
Here is a minimal program that captures the current moment, formats it for a log file, and parses a standard string back into a type.
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
fn main() {
// Capture the current system time with its local offset
let now = OffsetDateTime::now_local().unwrap();
// Define a format string once. The macro validates syntax at compile time.
let fmt = time::macros::format_description!(
"[year]-[month]-[day] [hour]:[minute]:[second] [offset_hour]:[offset_minute]"
);
// Convert the type into a human-readable string
let log_line = now.format(&fmt).unwrap();
println!("System clock: {log_line}");
// Parse a standard ISO 8601 string back into an OffsetDateTime
let input = "2023-10-27T10:00:00+00:00";
let parsed: OffsetDateTime = input.parse().unwrap();
// Shift the view to UTC without changing the underlying moment
let utc_view = parsed.to_offset(time::UtcOffset::UTC);
println!("UTC equivalent: {utc_view}");
}
Trust the macro. It catches typos before you ship.
What happens under the hood
The program starts by asking the operating system for the current wall clock time. now_local() returns a Result because the system might lack timezone data or the hardware clock might be misconfigured. The unwrap() panics on failure, which is acceptable for a throwaway example but dangerous in production.
The format_description! macro is where the crate earns its keep. You write the format specifiers in a readable syntax. The macro expands into a type-checked structure. If you typo [mounth] instead of [month], the compiler rejects the file before it runs. You never ship a binary that crashes because of a formatting typo.
Parsing works the opposite direction. The &str type implements FromStr for OffsetDateTime using the RFC 3339 standard by default. The parser validates the string structure, checks for impossible dates like February 30, and constructs the type. Converting to UTC with to_offset() does not mutate the underlying timestamp. It only changes the lens through which you view the same moment. The raw seconds since epoch remain identical. Only the displayed offset changes.
Convention aside: the community prefers time::UtcOffset::UTC over hardcoding 0 or +00:00. It reads clearly and avoids magic numbers. When you see UtcOffset::UTC, you know the intent immediately.
Production-ready patterns
Real applications need error handling and reusable format definitions. You also want to avoid repeating format strings across your codebase. Define them at the module level. Handle parsing failures gracefully.
use time::{
OffsetDateTime,
format_description::well_known::Rfc3339,
macros::format_description,
};
/// Module-level format for consistent logging across the application
const LOG_FORMAT: &[time::format_description::FormatItem<'static>] =
format_description!("[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3] [offset_hour sign:mandatory]:[offset_minute]");
/// Validates a user-submitted deadline and returns whether it has passed
fn check_deadline(user_input: &str) -> Result<bool, String> {
// Parse the input string into a timezone-aware type
let deadline: OffsetDateTime = user_input.parse().map_err(|e| {
format!("Invalid deadline format: {e}")
})?;
// Use UTC for server-side comparisons to avoid missing tzdata errors
let now = OffsetDateTime::now_utc().map_err(|e| {
format!("System clock unavailable: {e}")
})?;
// Compare two moments on the same timeline
Ok(now >= deadline)
}
fn main() {
let deadline_str = "2024-12-31T23:59:59+05:30";
match check_deadline(deadline_str) {
Ok(is_past) => println!("Deadline passed: {is_past}"),
Err(e) => eprintln!("Validation failed: {e}"),
}
}
The check_deadline function demonstrates proper error propagation. It returns a Result instead of panicking. The now_utc() call is safer than now_local() in containerized environments because it does not require system timezone databases. It reads the hardware clock directly and applies a zero offset. Comparing two OffsetDateTime values works correctly because the type normalizes both moments to the same universal timeline before evaluating the operator.
Unix timestamps deserve a separate mention. The crate provides from_unix_timestamp() and to_unix_timestamp(). These methods return a Result to handle overflow cases safely. Unix time only supports dates between 1970 and roughly 2262. Passing a year outside that range triggers an error. Always handle the Result. Never unwrap a timestamp conversion in network-facing code.
Keep your format definitions at the top of the file. It keeps your logic clean and makes auditing trivial.
Where things break
The time crate catches mistakes early, but it will still reject invalid input. If you pass a malformed string to .parse(), you get a time::error::Parse error. You must handle it. Ignoring it with .unwrap() turns a recoverable input error into a runtime panic.
Format strings are another trap. The macro syntax is strict. You cannot mix free-form text with format specifiers without proper escaping. If you need literal brackets in your output, double them: [[year]]. The compiler will complain if your format description contains unsupported tokens.
System environment matters. now_local() relies on the host machine's timezone configuration. Docker containers and CI runners often ship without tzdata. Calling now_local() in those environments returns an error. Switch to now_utc() for server-side logic. Reserve local time conversions for the presentation layer, right before you send data to a user interface.
Convention aside: the community treats time::macros::format_description! as the standard for all formatting. Passing raw string slices to .format() is possible but discouraged. The macro guarantees your specifiers exist. Raw strings silently fail at runtime.
Treat the Result as a contract. If you cannot handle the error, propagate it. Do not swallow it.
Choosing the right tool
Use OffsetDateTime when you need to track exact moments in time, compare events across different timezones, or store timestamps in a database. Use PrimitiveDateTime when you are working with calendar dates, recurring schedules, or data that explicitly lacks timezone information. Use the time crate when you want compile-time format validation, strict type separation between naive and aware dates, and a modern API without legacy baggage. Reach for chrono when you are maintaining an older codebase, need extensive timezone database lookups, or depend on third-party libraries that already export chrono types. Do not mix time and chrono in the same project. Their types do not implement each other's traits, and manual conversion adds unnecessary friction.
Pick the type that matches your data. Let the compiler enforce the boundary.