How to Parse Date Strings in Rust

Convert Rust strings to numbers using the .parse() method and handle errors with match or expect.

Parsing Date Strings in Rust

You're building a command-line tool that accepts a deadline. The user types 2024-03-15. You need to turn that string into a value you can compare, add days to, or store in a database. You reach for the .parse() method, expecting a magic Date type to appear. The compiler stops you. Rust's standard library does not include a date or time type.

This is a deliberate design choice. Dates and times are deceptively complex. Timezones, daylight saving transitions, leap seconds, and multiple calendar systems make a "correct" standard library implementation incredibly heavy. The Rust ecosystem delegates this to crates. The community has largely settled on chrono as the go-to library for general-purpose date and time work. This article covers how to parse date strings using chrono, how to handle errors, and when to reach for different types.

Why chrono and what is NaiveDate?

The chrono crate provides a hierarchy of types. The most common starting point is NaiveDate. The name "naive" means the type has no timezone information. It represents a calendar date in isolation: year, month, and day.

Use NaiveDate for birthdays, holidays, or deadlines where the time of day and timezone don't matter. A birthday is the same day everywhere. If you need timezone awareness, you'll use DateTime<Utc> or DateTime<Local>, but parsing usually starts with NaiveDate or a full RFC 3339 string.

Add chrono to your Cargo.toml:

[dependencies]
chrono = "0.4"

Minimal example: parsing with a format string

The core method for parsing is NaiveDate::parse_from_str. You pass the input string and a format string that describes the expected layout. The format string uses specifiers similar to the C strftime function.

use chrono::NaiveDate;

fn main() {
    let input = "2024-03-15";

    // %Y is four-digit year, %m is month, %d is day.
    // The dashes are literal characters that must match the input.
    let date = NaiveDate::parse_from_str(input, "%Y-%m-%d")
        .expect("Input must be a valid date in YYYY-MM-DD format");

    println!("Parsed date: {:?}", date);
}

parse_from_str returns a Result<NaiveDate, chrono::ParseError>. The Ok variant contains the parsed date. The Err variant contains a ParseError if the string doesn't match the format or represents an invalid date like February 30th. Using expect panics on error, which is acceptable for quick scripts or when the input is guaranteed. In production code, you handle the error explicitly.

The format string is a strict contract. If the input contains extra whitespace, trailing characters, or the wrong separators, parsing fails. chrono does not guess. It rejects anything that violates the schema.

The ISO 8601 shortcut

If your input follows the ISO 8601 standard (YYYY-MM-DD), you don't need a format string at all. chrono implements the FromStr trait for NaiveDate, which enables the .parse() method directly on strings.

use chrono::NaiveDate;

fn main() {
    let input = "2024-03-15";

    // FromStr assumes ISO 8601 for NaiveDate.
    // This is shorter and less error-prone than writing a format string.
    let date: NaiveDate = input.parse().expect("Invalid ISO date");

    println!("Parsed via FromStr: {:?}", date);
}

This is the idiomatic way to parse standard dates. The community convention is to use .parse() whenever the input is ISO 8601. It reduces boilerplate and eliminates the risk of typos in format specifiers. If you find yourself writing parse_from_str(input, "%Y-%m-%d"), switch to parse() instead.

Custom formats and common pitfalls

Real-world data rarely sticks to ISO 8601. You might encounter 15/03/2024, March 15, 2024, or 20240315. For these cases, parse_from_str is necessary. The format specifiers are case-sensitive and follow specific rules.

use chrono::NaiveDate;

fn main() {
    let input = "15/03/2024";

    // %d is day, %m is month, %Y is year.
    // Slashes are literal separators.
    let date = NaiveDate::parse_from_str(input, "%d/%m/%Y")
        .expect("Invalid date format");

    println!("European style: {:?}", date);
}

Format specifier typos are the most common source of bugs. The specifiers look similar but mean very different things.

  • %m is month (01-12). %M is minute (00-59). Using %M for month will fail or produce nonsense.
  • %Y is four-digit year (2024). %y is two-digit year (24).
  • %d is day of month (01-31). %D is US date format (mm/dd/yy).
  • %B is full month name (March). %b is abbreviated month (Mar).

If you mix these up, the parser rejects the input. The compiler won't catch format string errors because the format is a runtime string, not a type. You must test your format strings against representative data.

A classic trap is assuming %Y-%m-%d handles 24-03-15. It does not. %Y requires four digits. If the input has a two-digit year, you need %y-%m-%d. If the input varies, you need a strategy to try multiple formats.

Handling errors in production code

User input is messy. You need to handle parsing errors gracefully. The ? operator propagates errors up the call stack, which is the standard pattern in Rust.

use chrono::NaiveDate;

/// Attempts to parse a date string, returning a Result.
/// Callers can decide how to handle the error.
fn parse_deadline(input: &str) -> Result<NaiveDate, chrono::ParseError> {
    // Trim whitespace to handle accidental spaces around the input.
    let trimmed = input.trim();

    // Try ISO format first, as it's the most common standard.
    NaiveDate::parse_from_str(trimmed, "%Y-%m-%d")
}

fn main() {
    let user_input = " 2024-03-15 ";

    match parse_deadline(user_input) {
        Ok(date) => println!("Deadline set to: {:?}", date),
        Err(e) => eprintln!("Failed to parse date: {}", e),
    }
}

If you try to call .parse() on a string and the target type doesn't implement FromStr, the compiler rejects you with E0277 (trait bound not satisfied). This happens if you forget to import the type or if you're using a crate that doesn't support parsing. Ensure chrono::NaiveDate is in scope and that you're parsing into a type that supports it.

chrono::ParseError is opaque. It doesn't expose the partial data or the exact position of the failure. If parsing fails, you usually reject the entire input. You can't recover a year from a string where the month is invalid. This strictness is a feature. It prevents silent data corruption. If the parser fails, the input is wrong.

Parsing dates with time and timezone

When the string includes time and timezone information, NaiveDate is insufficient. You need DateTime<Utc> or DateTime<Local>. The chrono crate provides dedicated methods for standard formats like RFC 3339.

use chrono::{DateTime, Utc};

fn main() {
    let input = "2024-03-15T10:30:00Z";

    // parse_from_rfc3339 handles ISO 8601 / RFC 3339 strings with time and timezone.
    let datetime: DateTime<Utc> = DateTime::parse_from_rfc3339(input)
        .expect("Invalid RFC 3339 datetime");

    println!("Parsed datetime: {:?}", datetime);
}

parse_from_rfc3339 returns a DateTime<FixedOffset>. You can convert this to Utc using .with_timezone(&Utc). This method is robust and handles various timezone offsets like +05:30 or Z. If your input is always UTC, parse to DateTime<Utc> directly to avoid timezone conversion overhead.

For non-standard datetime strings, use DateTime::parse_from_str with a format string that includes time specifiers like %H (hour), %M (minute), and %S (second). The same pitfalls apply: %M is minute, not month. Be precise.

Trying multiple formats

Sometimes users provide dates in different formats. You can chain attempts to handle this. Return the first success or an error if all attempts fail.

use chrono::NaiveDate;

fn parse_flexible_date(input: &str) -> Result<NaiveDate, chrono::ParseError> {
    let trimmed = input.trim();

    // Try ISO 8601 first.
    if let Ok(date) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
        return Ok(date);
    }

    // Try European format.
    if let Ok(date) = NaiveDate::parse_from_str(trimmed, "%d/%m/%Y") {
        return Ok(date);
    }

    // Try US format.
    if let Ok(date) = NaiveDate::parse_from_str(trimmed, "%m/%d/%Y") {
        return Ok(date);
    }

    // All formats failed. Return the error from the last attempt.
    NaiveDate::parse_from_str(trimmed, "%Y-%m-%d")
}

This pattern works, but it has a downside: the error message always reflects the last format tried, which can be confusing. A better approach is to collect errors or return a custom error type that lists the accepted formats. For most applications, documenting the accepted formats and rejecting ambiguous input is safer than guessing. Ambiguity leads to bugs. If 01/02/2024 could be January 2nd or February 1st, force the user to disambiguate.

Decision: when to use which parsing method

Use chrono::NaiveDate::parse() when your input is standard ISO 8601 (YYYY-MM-DD) and you want the shortest, least error-prone code.

Use chrono::NaiveDate::parse_from_str() when the date format is non-standard, such as DD/MM/YYYY or Month DD, YYYY, and you need to specify the layout manually.

Use chrono::DateTime::parse_from_rfc3339() when your string includes time and timezone information, such as 2024-03-15T10:30:00Z, and you need a timezone-aware datetime object.

Use chrono::DateTime::parse_from_str() when you have a datetime string with a custom format that includes time components but doesn't match RFC 3339.

Use the time crate when you need maximum performance, zero-cost abstractions, or strict compile-time format validation, though you'll trade off convenience and a steeper learning curve.

Start with chrono. It handles 95% of use cases with minimal friction. Switch to time only when profiling proves that date parsing is a bottleneck or when you need features like compile-time format checking.

Where to go next