How does chrono::TimeZone::from_local_datetime handle ambiguous local times during daylight saving transitions?

chrono::TimeZone::from_local_datetime returns a LocalResult enum that represents three possible outcomes when converting a local datetime to a timezone-aware datetime: Single for unambiguous times, Ambiguous for times that occur twice during DST "fall back" transitions, and None for times that don't exist during DST "spring forward" transitions. This design forces callers to explicitly handle the edge cases that occur when a local time either doesn't exist or exists twice in a given timezone.

The DST Transition Problem

use chrono::{TimeZone, LocalResult, FixedOffset, NaiveDateTime};
use chrono::naive::NaiveDate;
 
fn dst_problem_explained() {
    // During DST "fall back" (autumn):
    // Clocks go from 2:00 AM back to 1:00 AM
    // So 1:30 AM occurs TWICE - once in DST, once in standard time
    
    // During DST "spring forward" (spring):
    // Clocks go from 1:59 AM to 3:00 AM
    // So 2:30 AM doesn't EXIST - it's skipped
    
    // This means a local time string like "2024-11-03 01:30:00"
    // could refer to two different UTC moments in US/Eastern
    // And "2024-03-10 02:30:00" doesn't exist at all in US/Eastern
    
    // chrono::TimeZone::from_local_datetime handles this by
    // returning LocalResult instead of panicking or guessing
}

DST transitions create two edge cases: ambiguous times (exist twice) and nonexistent times (skipped).

The LocalResult Enum

use chrono::{TimeZone, LocalResult, NaiveDateTime, FixedOffset};
 
fn local_result_enum() {
    // from_local_datetime returns LocalResult<T>
    // which has three variants:
    
    // LocalResult::Single(T) - unambiguous, single result
    // LocalResult::Ambiguous(T, T) - two possible results
    // LocalResult::None - local time doesn't exist
    
    let naive = NaiveDateTime::parse_from_str("2024-06-15 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
    let offset = FixedOffset::west_opt(5 * 3600).unwrap(); // UTC-5
    
    match offset.from_local_datetime(&naive) {
        LocalResult::Single(dt) => {
            println!("Unambiguous time: {}", dt);
        }
        LocalResult::Ambiguous(earliest, latest) => {
            println!("Ambiguous time: earliest={}, latest={}", earliest, latest);
        }
        LocalResult::None => {
            println!("Local time doesn't exist (skipped during spring forward)");
        }
    }
}

LocalResult encodes all three possibilities without guessing.

Single Result: Normal Times

use chrono::{TimeZone, LocalResult, FixedOffset, NaiveDateTime};
 
fn single_result() {
    let offset = FixedOffset::west_opt(5 * 3600).unwrap(); // UTC-5
    
    // Normal times that exist once
    let naive = NaiveDateTime::parse_from_str("2024-06-15 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
    
    let result = offset.from_local_datetime(&naive);
    
    match result {
        LocalResult::Single(dt) => {
            println!("Single result: {}", dt);
            // This is a normal time with one valid interpretation
        }
        _ => panic!("Expected single result"),
    }
    
    // Most times fall into this category
    // No DST transition occurring at this moment
    
    // unwrap() works for Single results
    let dt = result.unwrap();
    println!("Got datetime: {}", dt);
}

Most datetimes produce Single - unambiguous, single interpretation.

Ambiguous Result: Fall Back Transition

use chrono::{TimeZone, LocalResult, NaiveDateTime};
use chrono_tz::US::Eastern;
 
fn ambiguous_result() {
    // US Eastern DST fall back: 2024-11-03 at 2:00 AM
    // Clocks go from 2:00 AM DST back to 1:00 AM EST
    // So 1:30 AM occurs twice:
    // - 1:30 AM EDT (UTC-4) 
    // - 1:30 AM EST (UTC-5)
    
    // Create the ambiguous local time
    let naive = NaiveDateTime::parse_from_str("2024-11-03 01:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
    
    let result = Eastern.from_local_datetime(&naive);
    
    match result {
        LocalResult::Ambiguous(earliest, latest) => {
            println!("Ambiguous time!");
            println!("Earliest (DST):  {} (offset {})", earliest, earliest.offset());
            println!("Latest (STD):   {} (offset {})", latest, latest.offset());
            
            // earliest is the first occurrence (in EDT, UTC-4)
            // latest is the second occurrence (in EST, UTC-5)
        }
        LocalResult::Single(dt) => {
            println!("Single: {}", dt);
        }
        LocalResult::None => {
            println!("None");
        }
    }
    
    // The two times differ by 1 hour in UTC:
    // earliest: 2024-11-03 05:30:00 UTC
    // latest:   2024-11-03 06:30:00 UTC
}

During fall back, times in the transition hour are ambiguous - they occur twice.

None Result: Spring Forward Transition

use chrono::{TimeZone, LocalResult, NaiveDateTime};
use chrono_tz::US::Eastern;
 
fn none_result() {
    // US Eastern DST spring forward: 2024-03-10 at 2:00 AM
    // Clocks go from 1:59 AM EST directly to 3:00 AM EDT
    // So 2:30 AM doesn't exist - it's skipped
    
    let naive = NaiveDateTime::parse_from_str("2024-03-10 02:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
    
    let result = Eastern.from_local_datetime(&naive);
    
    match result {
        LocalResult::None => {
            println!("This local time doesn't exist!");
            println!("It was skipped during the spring forward transition.");
        }
        LocalResult::Single(_) | LocalResult::Ambiguous(_, _) => {
            panic!("Expected None for nonexistent time");
        }
    }
    
    // This is a time that never occurred in this timezone
    // The clock jumped from 1:59:59 AM to 3:00:00 AM
}

During spring forward, times in the skipped hour don't exist.

Handling Ambiguous Times

use chrono::{TimeZone, LocalResult, NaiveDateTime, DateTime};
use chrono_tz::US::Eastern;
 
fn handling_ambiguous() {
    let naive = NaiveDateTime::parse_from_str("2024-11-03 01:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
    
    let result = Eastern.from_local_datetime(&naive);
    
    // Strategy 1: Choose earliest (DST time)
    let earliest = match &result {
        LocalResult::Ambiguous(earliest, _latest) => earliest,
        LocalResult::Single(dt) => dt,
        LocalResult::None => panic!("Time doesn't exist"),
    };
    
    // Strategy 2: Choose latest (standard time)
    let latest = match &result {
        LocalResult::Ambiguous(_earliest, latest) => latest,
        LocalResult::Single(dt) => dt,
        LocalResult::None => panic!("Time doesn't exist"),
    };
    
    // Strategy 3: Use latest() method to get latest interpretation
    let dt_latest = result.latest();
    
    // Strategy 4: Use earliest() method to get earliest interpretation
    let dt_earliest = result.earliest();
    
    // Strategy 5: Use single() which returns None for ambiguous/none
    if let Some(dt) = result.single() {
        println!("Unambiguous: {}", dt);
    } else {
        println!("Time is ambiguous or doesn't exist");
    }
}

Several helper methods let you choose how to resolve ambiguity.

Handling Nonexistent Times

use chrono::{TimeZone, LocalResult, NaiveDateTime};
use chrono_tz::US::Eastern;
 
fn handling_nonexistent() {
    let naive = NaiveDateTime::parse_from_str("2024-03-10 02:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
    
    let result = Eastern.from_local_datetime(&naive);
    
    // Result is LocalResult::None
    
    // Strategy 1: Reject and report error
    match result {
        LocalResult::None => {
            eprintln!("Error: Invalid time - doesn't exist in this timezone");
        }
        LocalResult::Single(dt) => println!("Valid: {}", dt),
        LocalResult::Ambiguous(_, _) => println!("Ambiguous"),
    }
    
    // Strategy 2: Use a fallback (e.g., next valid time)
    // chrono doesn't provide this directly, you'd need custom logic
    
    // Strategy 3: Advance by the missing duration
    // If 2:00-3:00 AM is skipped, 2:30 AM becomes 3:30 AM
    // This requires manual adjustment
    
    // Strategy 4: Use and_timezon-methods in newer chrono versions
    // Some methods handle this automatically with different strategies
}

Nonexistent times require custom handling or rejection.

Using Helper Methods

use chrono::{TimeZone, LocalResult, NaiveDateTime};
use chrono_tz::US::Eastern;
 
fn helper_methods() {
    // single() - Returns Some(T) only for Single result
    let normal_time = NaiveDateTime::parse_from_str("2024-06-15 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
    let single_result = Eastern.from_local_datetime(&normal_time);
    
    if let Some(dt) = single_result.single() {
        println!("Got single datetime: {}", dt);
    }
    
    // earliest() - Returns earliest for Ambiguous, the value for Single, None for None
    let ambiguous_time = NaiveDateTime::parse_from_str("2024-11-03 01:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
    let amb_result = Eastern.from_local_datetime(&ambiguous_time);
    
    let earliest = amb_result.earliest();
    // earliest is Some(DateTime) - the first interpretation
    
    // latest() - Returns latest for Ambiguous, the value for Single, None for None
    let latest = amb_result.latest();
    // latest is Some(DateTime) - the second interpretation
    
    // For None result:
    let none_time = NaiveDateTime::parse_from_str("2024-03-10 02:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
    let none_result = Eastern.from_local_datetime(&none_time);
    
    assert!(none_result.single().is_none());
    assert!(none_result.earliest().is_none());
    assert!(none_result.latest().is_none());
}

single(), earliest(), and latest() provide convenient access patterns.

FixedOffset vs Timezone with DST

use chrono::{TimeZone, LocalResult, FixedOffset, NaiveDateTime};
 
fn fixed_offset() {
    // FixedOffset doesn't have DST transitions
    // All times are unambiguous (Single result)
    
    let offset = FixedOffset::west_opt(5 * 3600).unwrap(); // UTC-5 always
    
    let naive = NaiveDateTime::parse_from_str("2024-11-03 01:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
    
    let result = offset.from_local_datetime(&naive);
    
    match result {
        LocalResult::Single(dt) => {
            println!("FixedOffset always gives Single: {}", dt);
        }
        _ => unreachable!("FixedOffset never produces ambiguous or none"),
    }
    
    // FixedOffset is simpler but doesn't handle real-world DST
    // Use timezone types (chrono-tz, etc.) for DST-aware handling
}

FixedOffset always produces Single since it has no DST transitions.

Using chrono-tz Timezones

use chrono::{TimeZone, LocalResult, NaiveDateTime};
use chrono_tz::{US::Eastern, US::Pacific, Europe::London};
 
fn timezone_examples() {
    // US Eastern (New York)
    let ny_time = NaiveDateTime::parse_from_str("2024-11-03 01:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
    let ny_result = Eastern.from_local_datetime(&ny_time);
    // Ambiguous during fall back
    
    // US Pacific (Los Angeles)  
    let la_time = NaiveDateTime::parse_from_str("2024-03-10 02:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
    let la_result = Pacific.from_local_datetime(&la_time);
    // None during spring forward
    
    // Europe/London
    let uk_time = NaiveDateTime::parse_from_str("2024-10-27 01:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
    let uk_result = London.from_local_datetime(&uk_time);
    // Different DST dates - EU transitions are different from US
}

Different timezones have different DST transition dates and times.

Custom Ambiguity Resolution

use chrono::{TimeZone, LocalResult, NaiveDateTime, DateTime};
use chrono_tz::US::Eastern;
 
#[derive(Debug, Clone, Copy)]
enum AmbiguityResolution {
    Earliest,  // DST time (earlier in UTC)
    Latest,    // Standard time (later in UTC)
    Reject,    // Return error for ambiguous
}
 
fn resolve_local_time(
    naive: NaiveDateTime,
    tz: &impl TimeZone,
    resolution: AmbiguityResolution,
) -> Option<DateTime<tz::TimeZone>> {
    // Note: This is conceptual - actual type bounds would need adjustment
    match tz.from_local_datetime(&naive) {
        LocalResult::Single(dt) => Some(dt),
        LocalResult::Ambiguous(earliest, latest) => {
            match resolution {
                AmbiguityResolution::Earliest => Some(earliest),
                AmbiguityResolution::Latest => Some(latest),
                AmbiguityResolution::Reject => None,
            }
        }
        LocalResult::None => None,
    }
}
 
fn custom_resolution_example() {
    let naive = NaiveDateTime::parse_from_str("2024-11-03 01:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
    
    // Resolve with earliest (DST time)
    let result = Eastern.from_local_datetime(&naive);
    let chosen = match result {
        LocalResult::Ambiguous(earliest, _) => earliest,
        LocalResult::Single(dt) => dt,
        LocalResult::None => panic!("Nonexistent time"),
    };
    println!("Chose earliest: {}", chosen);
}

Encapsulate your ambiguity resolution strategy in a reusable function.

Practical Example: User Input Handling

use chrono::{TimeZone, LocalResult, NaiveDateTime};
use chrono_tz::US::Eastern;
 
fn handle_user_input(date_str: &str, time_str: &str) -> Result<String, String> {
    let datetime_str = format!("{} {}", date_str, time_str);
    let naive = NaiveDateTime::parse_from_str(&datetime_str, "%Y-%m-%d %H:%M:%S")
        .map_err(|e| format!("Invalid datetime format: {}", e))?;
    
    let result = Eastern.from_local_datetime(&naive);
    
    match result {
        LocalResult::Single(dt) => {
            Ok(format!("Scheduled for: {}", dt))
        }
        LocalResult::Ambiguous(earliest, latest) => {
            Err(format!(
                "Ambiguous time during DST transition. \
                 Possible times:\n  - {} (DST)\n  - {} (Standard)\n\
                 Please specify the offset or use a different time.",
                earliest, latest
            ))
        }
        LocalResult::None => {
            Err(format!(
                "Invalid time: {} doesn't exist (skipped during DST spring forward). \
                 Please choose a time after 3:00 AM.",
                datetime_str
            ))
        }
    }
}
 
fn user_input_example() {
    // Normal time
    match handle_user_input("2024-06-15", "14:30:00") {
        Ok(msg) => println!("{}", msg),
        Err(e) => eprintln!("Error: {}", e),
    }
    
    // Ambiguous time
    match handle_user_input("2024-11-03", "01:30:00") {
        Ok(msg) => println!("{}", msg),
        Err(e) => println!("{}", e),
    }
    
    // Nonexistent time
    match handle_user_input("2024-03-10", "02:30:00") {
        Ok(msg) => println!("{}", msg),
        Err(e) => println!("{}", e),
    }
}

User-facing applications need clear error messages for DST edge cases.

Checking for DST Transitions

use chrono::{TimeZone, LocalResult, NaiveDateTime, NaiveDate};
use chrono_tz::US::Eastern;
 
fn is_ambiguous_date(date: NaiveDate, tz: &impl TimeZone) -> bool {
    // Check if any time on this date could be ambiguous
    // Typically the day of fall-back transition
    
    let start = date.and_hms_opt(0, 0, 0).unwrap();
    
    // Check a few hours that are commonly affected
    for hour in 0..=3 {
        if let Some(time) = start.with_hour(hour) {
            if matches!(tz.from_local_datetime(&time), LocalResult::Ambiguous(_, _)) {
                return true;
            }
        }
    }
    false
}
 
fn is_transition_date(date: NaiveDate) {
    // US DST transitions in 2024:
    // Spring forward: March 10 (second Sunday of March)
    // Fall back: November 3 (first Sunday of November)
    
    println!("March 10, 2024 has spring forward (nonexistent times)");
    println!("November 3, 2024 has fall back (ambiguous times)");
}

Detect DST transition dates to warn users about potential issues.

Comparison with Local.from_local_datetime

use chrono::{TimeZone, LocalResult, Local, NaiveDateTime};
 
fn local_timezone() {
    // Local timezone uses the system's timezone
    let naive = NaiveDateTime::parse_from_str("2024-06-15 12:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
    
    let result = Local.from_local_datetime(&naive);
    
    match result {
        LocalResult::Single(dt) => {
            println!("Local time: {}", dt);
            // If system is in DST timezone, this handles DST correctly
        }
        LocalResult::Ambiguous(earliest, latest) => {
            println!("Ambiguous in local timezone");
        }
        LocalResult::None => {
            println!("Nonexistent in local timezone");
        }
    }
    
    // Local has same DST issues as other timezones
    // Depends on system timezone configuration
}

Local timezone also has DST transitions and returns the same LocalResult variants.

Summary Table

fn summary() {
    // | LocalResult     | Meaning                    | Example Time         |
    // |-----------------|---------------------------|---------------------|
    // | Single(dt)      | Unambiguous, one result   | Normal time         |
    // | Ambiguous(a, b) | Exists twice (fall back) | 1:30 AM during DST  |
    // | None            | Doesn't exist (spring fwd)| 2:30 AM during DST  |
    
    // | Method      | Single         | Ambiguous        | None   |
    // |-------------|----------------|------------------|--------|
    // | single()    | Some(dt)       | None             | None   |
    // | earliest()  | Some(dt)       | Some(earliest)   | None   |
    // | latest()    | Some(dt)       | Some(latest)     | None   |
    // | unwrap()    | dt             | panic!           | panic! |
    
    // | DST Transition | Local Times Affected  | Result Type   |
    // |----------------|-----------------------|--------------|
    // | Spring forward | Skipped hour (1-3 AM) | None         |
    // | Fall back      | Repeated hour (1-2 AM)| Ambiguous    |
}

Synthesis

Quick reference:

use chrono::{TimeZone, LocalResult, NaiveDateTime};
use chrono_tz::US::Eastern;
 
let naive = NaiveDateTime::parse_from_str("2024-11-03 01:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
 
match Eastern.from_local_datetime(&naive) {
    LocalResult::Single(dt) => {
        // Unambiguous time - normal case
        println!("Time: {}", dt);
    }
    LocalResult::Ambiguous(earliest, latest) => {
        // Time exists twice during DST fall back
        // earliest = DST time (first occurrence, UTC-4 for Eastern)
        // latest = Standard time (second occurrence, UTC-5 for Eastern)
        println!("Ambiguous: {} or {}", earliest, latest);
    }
    LocalResult::None => {
        // Time doesn't exist during DST spring forward
        println!("Nonexistent time - skipped during transition");
    }
}
 
// Convenience methods:
let dt = result.single();    // None for ambiguous/nonexistent
let dt = result.earliest();  // First interpretation or None
let dt = result.latest();     // Last interpretation or None

Key insight: from_local_datetime returns LocalResult precisely because converting a local time string to a timezone-aware datetime is not always a well-defined operation during DST transitions. When clocks "fall back" in autumn, a local time like 1:30 AM occurs twice—once in daylight time and once in standard time. The Ambiguous(earliest, latest) variant captures both possibilities, with earliest being the first occurrence (earlier in UTC, typically in DST) and latest being the second (later in UTC, typically in standard time). When clocks "spring forward" in spring, times in the skipped hour (typically 2:00 AM to 3:00 AM in the US) never occur at all—LocalResult::None signals this impossibility. The LocalResult type forces you to acknowledge these edge cases rather than silently guessing. Use .single() when you only want unambiguous times (returns None for ambiguous/nonexistent), .earliest() or .latest() when you have a preferred resolution strategy for ambiguous times, or pattern match to provide custom error messages for user-facing applications. FixedOffset timezones avoid this complexity because they have no DST transitions—all local times produce Single results—but they also can't represent real-world timezone behavior.