How to Format Dates and Times in Rust

Format dates and times in Rust using the chrono crate and format strings.

When the raw timestamp isn't enough

You're writing a log file. The timestamp prints as 2024-05-20T14:30:00.000000000Z. It's precise, but your user wants May 20, 2024 at 2:30 PM. Or you're generating a CSV and the downstream parser demands YYYY/MM/DD. You have the time. You just need to shape it into the right string.

Rust's standard library gives you the clock, but it doesn't give you the calendar. The std crate has SystemTime, which is a raw count of nanoseconds since an epoch. It's great for measuring intervals. It's terrible for printing. For actual dates, times, and formatting, the ecosystem relies on external crates. chrono is the workhorse. It wraps the raw time in types that understand years, months, time zones, and leap seconds.

Formatting works like a stencil. You provide a pattern string with placeholders. The crate punches holes in the pattern and fills them with the actual values from your date. The result is a string that matches your exact requirements.

Minimal example

The chrono crate provides the format method on DateTime types. You pass a pattern string, and the method returns a formatter object. You then convert that formatter to a String or print it directly.

use chrono::{DateTime, Utc};

fn main() {
    // Capture the current instant in UTC.
    let now: DateTime<Utc> = Utc::now();

    // Define the output shape using strftime tokens.
    let pattern = "%Y-%m-%d %H:%M:%S";

    // format() returns a DelayedFormat, not a String.
    // Call to_string() to allocate and materialize the result.
    let formatted = now.format(pattern).to_string();

    println!("{formatted}");
}

Call .to_string() to materialize the result. The formatter is lazy by design.

How the formatter works

The format method doesn't return a String. It returns a DelayedFormat object. This is a performance trick. The actual string allocation happens only when you call .to_string() or pass the formatter to println!. If you just call format and drop the result, no memory is allocated. This saves overhead in hot loops where you might format a value but not always use the string.

The pattern string uses strftime tokens. These are percent signs followed by letters. %Y is the four-digit year. %m is the zero-padded month. %H is the 24-hour clock. The crate parses the pattern, looks up the values in the DateTime, and substitutes them. The tokens are case-sensitive. %Y gives the full year. %y gives the last two digits. %m is the month number. %M is the minute. Swap them and your date becomes nonsense.

The crate doesn't warn you about semantic errors in the pattern. It just substitutes whatever token you asked for. If you write %Y-%M-%d, you get the year, then the minute, then the day. The output looks like a date but contains time data. Double-check your tokens against the documentation.

Realistic scenario: Log entries with local time

Real applications often need to convert time zones before formatting. You might store everything in UTC but display logs in the user's local time. The with_timezone method handles this conversion. It changes the view of the instant without changing the underlying moment.

use chrono::{DateTime, Local, Utc};

/// Formats a UTC timestamp into a human-readable local log entry.
fn format_log_entry(event: &str, timestamp: DateTime<Utc>) -> String {
    // Convert the UTC instant to the system's local time zone.
    // The underlying moment in time remains unchanged.
    let local_time = timestamp.with_timezone(&Local);

    // Build the date part with day/month/year order.
    let date_part = local_time.format("%d/%m/%Y");

    // Build the time part with milliseconds for precision.
    let time_part = local_time.format("%H:%M:%S%.3f");

    // Combine parts into the final message using the format macro.
    format!("[{date_part} {time_part}] {event}")
}

fn main() {
    let now = Utc::now();
    let log = format_log_entry("User login", now);
    println!("{log}");
}

Convert to local time only at the boundary. Keep your internal state in UTC.

The Display trait connection

The format method works because DateTime implements the Display trait. When you use {} in a format string, Rust calls Display::fmt. The format method is just a specialized way to provide a custom pattern. You can also use the DateTime directly in the format! macro if you want the default ISO 8601 representation.

use chrono::Utc;

fn main() {
    let now = Utc::now();

    // Default Display output is ISO 8601.
    println!("Default: {}", now);

    // Custom pattern via the format method.
    println!("Custom: {}", now.format("%Y-%m-%d"));
}

This connects formatting to the broader Rust trait system. Any type that implements Display can be formatted with {}. chrono types implement Display to provide sensible defaults. You can implement Display for your own types to control how they print. This is a standard Rust pattern for custom output.

Common patterns and tokens

The community treats chrono as the standard date library. Most crates that handle dates expect chrono types. If you're starting a new project, chrono is the safe default. The format tokens follow the C strftime convention. This means the same pattern strings work across many languages and tools. If you copy a format string from Python or JavaScript, it will likely work in Rust with chrono. This compatibility reduces friction when sharing data formats.

ISO 8601 is the default for machine exchange. Use %Y-%m-%dT%H:%M:%S%.fZ for full precision with fractional seconds. Use %Y-%m-%d for simple dates without time. Use %b %d, %Y for human-readable dates like May 20, 2024. Use %H:%M for times without seconds. The token %.f adds fractional seconds with variable precision. %.3f forces exactly three digits for milliseconds.

Formatting doesn't change the underlying instant. It only changes the string representation. If you format a DateTime<Utc> with a pattern that includes %Z, you get UTC. If you format a DateTime<Local>, you get the local abbreviation. The zone information is part of the type, not the format string. This prevents accidental zone loss.

Pitfalls and compiler errors

The compiler rejects this with E0308 (mismatched types) if you assign the result of format directly to a String. The method returns a formatter object. You must call .to_string() to get the actual string. This design avoids allocating memory when you only need to print the value once. If you forget the conversion, the compiler points out the type mismatch immediately.

use chrono::Utc;

fn main() {
    let now = Utc::now();

    // This fails with E0308: mismatched types.
    // expected struct `String`, found struct `DelayedFormat`.
    // let bad: String = now.format("%Y-%m-%d");

    // This works: convert to String explicitly.
    let good: String = now.format("%Y-%m-%d").to_string();
}

Pattern tokens are case-sensitive and strict. %Y gives the full year. %y gives the last two digits. %m is the month number. %M is the minute. Swap them and your date becomes nonsense. The crate doesn't warn you about semantic errors in the pattern. It just substitutes whatever token you asked for. If you write %Y-%M-%d, you get the year, then the minute, then the day. The output looks like a date but contains time data. Double-check your tokens against the documentation.

Locale awareness adds complexity. chrono supports locales, but it requires the unstable-locales feature or external data. By default, formatting uses English names for months and days. If you need Janvier instead of January, you need to enable locale support. This adds complexity and binary size. For most applications, English tokens or numeric dates are sufficient. Only enable locales if you have a specific user-facing requirement.

Trust the tokens. If the output looks wrong, the pattern is wrong, not the crate.

Decision: Choosing your date strategy

Use chrono when you need full calendar arithmetic, time zone handling, and a rich ecosystem of integrations. It's the most widely used date library in Rust. Use the time crate when you need minimal dependencies, strict zero-cost abstractions, or are building a library where compile time and binary size matter more than convenience. Use DateTime<Utc> for storage and computation when you want a single, unambiguous representation of an instant. Use DateTime<Local> only for display when you need to show the time in the user's current zone. Use NaiveDateTime when you are working with dates that have no time zone context, like a birthday or a historical event, and you want to avoid the overhead of zone conversion.

Pick the tool that matches your precision needs. UTC for logic, Local for display.

Where to go next