How does chrono::TimeZone::from_local_datetime handle ambiguous or non-existent local times?
from_local_datetime returns a LocalResult enum that explicitly represents the three possible outcomes when converting a local datetime to a timezone-aware datetime: Single for unambiguous times, Ambiguous for times that occur twice during a backward timezone transition, and None for times that don't exist during a forward timezone transition. This design forces callers to handle edge cases rather than silently losing information or panicking.
Basic Usage with Unambiguous Times
use chrono::{TimeZone, NaiveDateTime, Utc, LocalResult};
fn unambiguous_time() {
// Most times are unambiguous - they map to exactly one UTC time
let naive = NaiveDateTime::parse_from_str("2024-06-15 14:30:00", "%Y-%m-%d %H:%M:%S")
.unwrap();
let result = Utc.from_local_datetime(&naive);
match result {
LocalResult::Single(datetime) => {
println!("Unambiguous mapping: {}", datetime);
// datetime is a DateTime<Utc>
}
LocalResult::Ambiguous(earliest, latest) => {
// Won't happen for most times
println!("Ambiguous: {} or {}", earliest, latest);
}
LocalResult::None => {
// Won't happen for most times
println!("No valid mapping");
}
}
}The common case produces LocalResult::Single with exactly one valid mapping.
Understanding Ambiguous Times
use chrono::{TimeZone, NaiveDateTime, FixedOffset, LocalResult};
fn ambiguous_time_fall_back() {
// During "fall back" (end of DST), a local time occurs twice:
// Example: US Eastern "fall back" from EDT to EST
// 1:30 AM EDT becomes 1:30 AM EST (same local time twice)
// Create a timezone that has DST transitions
let eastern = FixedOffset::west_opt(5 * 3600).unwrap(); // Simplified: EST
// In practice, use chrono-tz for real timezone with DST:
// use chrono_tz::US::Eastern;
// In November, 1:30 AM local time occurs twice
// A time like "2024-11-03 01:30:00" in US Eastern is ambiguous
// It could be:
// - 2024-11-03 01:30:00 EDT (UTC-4)
// - 2024-11-03 01:30:00 EST (UTC-5)
// The result is LocalResult::Ambiguous with both possibilities
// earliest = the time in the earlier timezone (EDT)
// latest = the time in the later timezone (EST)
}
fn handle_ambiguous() {
// When from_local_datetime returns Ambiguous:
// earliest: the time expressed in the earlier offset (before transition)
// latest: the time expressed in the later offset (after transition)
// Example: 1:30 AM on fall-back day
// earliest = 1:30 AM EDT = 05:30 UTC
// latest = 1:30 AM EST = 06:30 UTC
// Applications must decide which one to use:
// - Use earliest: assume time was before transition
// - Use latest: assume time was after transition
// - Ask user for clarification
// - Use some business logic to decide
}Ambiguous times occur during "fall back" when clocks are set backward, causing some local times to repeat.
Understanding Non-Existent Times
use chrono::{TimeZone, NaiveDateTime, LocalResult};
fn non_existent_time_spring_forward() {
// During "spring forward" (start of DST), some local times don't exist:
// Example: US Eastern "spring forward" from EST to EDT
// 2:00 AM EST becomes 3:00 AM EDT
// Times from 2:00 AM to 2:59 AM don't exist!
// A time like "2024-03-10 02:30:00" in US Eastern doesn't exist
// Clocks jump from 1:59 AM EST directly to 3:00 AM EDT
// The result is LocalResult::None
// This is why from_local_datetime can't just return DateTime:
// there's no valid UTC time for this local time
}
fn handle_non_existent() {
// When from_local_datetime returns None:
// The local time simply doesn't exist in that timezone
// Applications can handle this by:
// - Using the next valid time (forward adjustment)
// - Using the previous valid time (backward adjustment)
// - Returning an error to the user
// - Using a default behavior for missing times
}Non-existent times occur during "spring forward" when clocks are set forward, skipping some local times.
The LocalResult Enum
use chrono::{DateTime, TimeZone, NaiveDateTime};
pub enum LocalResult<T> {
/// Given local time is unique in the timezone
Single(T),
/// Given local time occurs twice (e.g., during fall-back)
/// Contains (earliest, latest) where earliest < latest in UTC
Ambiguous(T, T),
/// Given local time does not exist (e.g., during spring-forward)
None,
}
// The type parameter T is typically DateTime<TimeZone>
fn result_types() {
let naive = NaiveDateTime::parse_from_str("2024-06-15 12:00:00", "%Y-%m-%d %H:%M:%S")
.unwrap();
let result: LocalResult<DateTime<chrono::Utc>> =
chrono::Utc.from_local_datetime(&naive);
// For most timezones and times, result is Single
}LocalResult forces explicit handling of all three cases.
Practical Handling Patterns
use chrono::{TimeZone, NaiveDateTime, DateTime, Utc, LocalResult};
// Pattern 1: Assume earliest for ambiguous, skip for non-existent
fn assume_earliest_or_fail<Tz: TimeZone>(tz: &Tz, naive: &NaiveDateTime) -> Option<DateTime<Tz>> {
match tz.from_local_datetime(naive) {
LocalResult::Single(dt) => Some(dt),
LocalResult::Ambiguous(earliest, _latest) => Some(earliest),
LocalResult::None => None,
}
}
// Pattern 2: Assume latest for ambiguous, skip for non-existent
fn assume_latest_or_fail<Tz: TimeZone>(tz: &Tz, naive: &NaiveDateTime) -> Option<DateTime<Tz>> {
match tz.from_local_datetime(naive) {
LocalResult::Single(dt) => Some(dt),
LocalResult::Ambiguous(_earliest, latest) => Some(latest),
LocalResult::None => None,
}
}
// Pattern 3: Forward adjustment for non-existent times
fn forward_adjust<Tz: TimeZone>(tz: &Tz, naive: &NaiveDateTime) -> Option<DateTime<Tz>>
where Tz::Offset: Copy {
match tz.from_local_datetime(naive) {
LocalResult::Single(dt) => Some(dt),
LocalResult::Ambiguous(earliest, _) => Some(earliest),
LocalResult::None => {
// Find the next valid time by adding offset
// This is approximate - real implementation needs offset info
None
}
}
}
// Pattern 4: Unwrap with a default
fn unwrap_with_default<Tz: TimeZone>(tz: &Tz, naive: &NaiveDateTime, default: DateTime<Tz>) -> DateTime<Tz> {
match tz.from_local_datetime(naive) {
LocalResult::Single(dt) => dt,
LocalResult::Ambiguous(earliest, _) => earliest,
LocalResult::None => default,
}
}Different applications need different strategies for handling edge cases.
Using chrono-tz for Real Timezone Transitions
// Requires chrono-tz crate for timezone-aware DST transitions
use chrono::{NaiveDateTime, TimeZone};
use chrono_tz::US::Eastern;
fn real_timezone_example() {
// November 5, 2023 1:30 AM Eastern is ambiguous
// (clocks fall back from EDT to EST)
let naive = NaiveDateTime::parse_from_str("2023-11-05 01:30:00", "%Y-%m-%d %H:%M:%S")
.unwrap();
match Eastern.from_local_datetime(&naive) {
LocalResult::Ambiguous(earliest, latest) => {
println!("Ambiguous time:");
println!(" Earlier (EDT): {}", earliest);
println!(" Later (EST): {}", latest);
// The difference is 1 hour in UTC time
let diff = latest.timestamp() - earliest.timestamp();
println!(" UTC difference: {} seconds (1 hour)", diff);
}
LocalResult::Single(dt) => {
println!("Single: {}", dt);
}
LocalResult::None => {
println!("Non-existent");
}
}
// March 12, 2023 2:30 AM Eastern doesn't exist
// (clocks spring forward from EST to EDT)
let naive = NaiveDateTime::parse_from_str("2023-03-12 02:30:00", "%Y-%m-%d %H:%M:%S")
.unwrap();
match Eastern.from_local_datetime(&naive) {
LocalResult::None => {
println!("Time doesn't exist due to DST spring-forward");
}
_ => unreachable!(),
}
}Real timezone handling requires chrono-tz for accurate DST transitions.
Conversion Methods Comparison
use chrono::{TimeZone, NaiveDateTime, Utc, LocalResult};
fn conversion_methods() {
let naive = NaiveDateTime::parse_from_str("2024-06-15 12:00:00", "%Y-%m-%d %H:%M:%S")
.unwrap();
// from_local_datetime: Returns LocalResult (handles edge cases)
let result: LocalResult<DateTime<Utc>> = Utc.from_local_datetime(&naive);
// Must handle all three cases
// single: Unwrap, assuming no ambiguity
// Panics on Ambiguous or None
let dt: DateTime<Utc> = Utc.from_local_datetime(&naive).single().unwrap();
// Use only when you're certain the time is unambiguous
// earliest: For ambiguous times, take the earlier occurrence
// Returns None for non-existent times
let maybe_dt: Option<DateTime<Utc>> = Utc.from_local_datetime(&naive).earliest();
// latest: For ambiguous times, take the later occurrence
// Returns None for non-existent times
let maybe_dt: Option<DateTime<Utc>> = Utc.from_local_datetime(&naive).latest();
// For fixed-offset timezones (no DST), from_local_datetime always returns Single
let fixed = chrono::FixedOffset::east_opt(5 * 3600).unwrap();
let result = fixed.from_local_datetime(&naive);
assert!(matches!(result, LocalResult::Single(_)));
}LocalResult provides methods for common unwrapping patterns.
User Input Handling
use chrono::{NaiveDateTime, TimeZone, LocalResult};
use chrono_tz::Tz;
#[derive(Debug)]
pub enum TimeConversionError {
AmbiguousTime { earliest: String, latest: String },
NonExistentTime,
}
fn parse_user_time(input: &str, timezone: Tz) -> Result<String, TimeConversionError> {
let naive = NaiveDateTime::parse_from_str(input, "%Y-%m-%d %H:%M:%S")
.map_err(|_| TimeConversionError::NonExistentTime)?;
match timezone.from_local_datetime(&naive) {
LocalResult::Single(dt) => {
Ok(format!("Time: {} (unambiguous)", dt))
}
LocalResult::Ambiguous(earliest, latest) => {
Err(TimeConversionError::AmbiguousTime {
earliest: format!("{}", earliest),
latest: format!("{}", latest),
})
}
LocalResult::None => {
Err(TimeConversionError::NonExistentTime)
}
}
}
fn handle_user_input() {
// In a real application, you might:
// 1. Show user the ambiguous options and let them choose
// 2. For non-existent time, suggest valid alternatives
// 3. Store the chosen interpretation
// For scheduled events, always store UTC to avoid ambiguity
// Record the timezone for display purposes
}When accepting user input, provide clear feedback about ambiguous or non-existent times.
Scheduling Systems
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc, LocalResult};
use chrono_tz::Tz;
// For scheduling, always convert to and store UTC
fn schedule_event(local_time: &str, timezone: Tz) -> Result<DateTime<Utc>, String> {
let naive = NaiveDateTime::parse_from_str(local_time, "%Y-%m-%d %H:%M:%S")
.map_err(|e| format!("Invalid datetime: {}", e))?;
match timezone.from_local_datetime(&naive) {
LocalResult::Single(aware) => {
// Unambiguous - convert to UTC for storage
Ok(aware.with_timezone(&Utc))
}
LocalResult::Ambiguous(earliest, latest) => {
// Ambiguous - must choose one
// Common choice: earliest (assume DST hasn't ended yet)
// Or: reject and ask user
Ok(earliest.with_timezone(&Utc))
}
LocalResult::None => {
// Non-existent - adjust forward
// Find the next valid time after the gap
Err("Time doesn't exist due to DST transition".to_string())
}
}
}
// For recurring events, consider DST implications
fn recurring_event(start: DateTime<Utc>, interval_hours: u64, timezone: Tz) {
// "9 AM every day" in local time is tricky with DST
// - Some days are 23 or 25 hours
// - The UTC offset changes
// Better: Store "9 AM local time" and convert each occurrence
// Or: Store UTC and accept that local time shifts with DST
}Scheduling systems must handle DST transitions explicitly.
Database Storage Patterns
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc, LocalResult};
// Pattern 1: Store as UTC, display in local timezone
fn store_as_utc<Tz: TimeZone>(naive: &NaiveDateTime, tz: &Tz) -> Option<DateTime<Utc>>
where Tz::Offset: Copy {
match tz.from_local_datetime(naive) {
LocalResult::Single(aware) => Some(aware.with_timezone(&Utc)),
LocalResult::Ambiguous(earliest, _) => Some(earliest.with_timezone(&Utc)),
LocalResult::None => None,
}
}
// Pattern 2: Store offset information
struct StoredDateTime {
utc_time: DateTime<Utc>,
offset_seconds: i32, // Store the actual offset used
timezone_name: String,
}
fn store_with_offset<Tz: TimeZone>(naive: &NaiveDateTime, tz: &Tz, name: &str)
-> Option<StoredDateTime>
{
match tz.from_local_datetime(naive) {
LocalResult::Single(aware) => {
let offset = aware.offset().clone();
Some(StoredDateTime {
utc_time: aware.with_timezone(&Utc),
offset_seconds: offset.local_minus_utc(),
timezone_name: name.to_string(),
})
}
LocalResult::Ambiguous(earliest, _latest) => {
let offset = earliest.offset().clone();
Some(StoredDateTime {
utc_time: earliest.with_timezone(&Utc),
offset_seconds: offset.local_minus_utc(),
timezone_name: name.to_string(),
})
}
LocalResult::None => None,
}
}
// Pattern 3: Store local time plus timezone, convert on read
struct StoredLocalTime {
local_time: NaiveDateTime, // Store what user entered
timezone: String,
// Re-interpret on read, handle ambiguity then
}Store UTC for correctness; store local representation for user display.
Comparison with from_utc_datetime
use chrono::{TimeZone, NaiveDateTime, Utc};
fn comparison() {
let naive_utc = NaiveDateTime::parse_from_str("2024-06-15 12:00:00", "%Y-%m-%d %H:%M:%S")
.unwrap();
// from_utc_datetime: Never ambiguous or non-existent
// A UTC time maps to exactly one local time
let local = Utc.from_utc_datetime(&naive_utc);
// Always returns DateTime directly, no LocalResult
// No ambiguity because UTC has no DST transitions
// from_local_datetime: Can be ambiguous or non-existent
// A local time might map to 0, 1, or 2 UTC times
let naive_local = NaiveDateTime::parse_from_str("2024-06-15 12:00:00", "%Y-%m-%d %H:%M:%S")
.unwrap();
// Returns LocalResult because of potential ambiguity
let result = Utc.from_local_datetime(&naive_local);
}
// Key insight:
// - UTC -> Local: Always unambiguous (from_utc_datetime)
// - Local -> UTC: Can be ambiguous/non-existent (from_local_datetime)Converting from UTC is always unambiguous; converting to UTC requires handling edge cases.
Fixed Offset Timezones
use chrono::{FixedOffset, TimeZone, NaiveDateTime, LocalResult};
fn fixed_offset() {
// Fixed-offset timezones have no DST transitions
// Therefore, from_local_datetime always returns Single
let est = FixedOffset::west_opt(5 * 3600).unwrap(); // UTC-5
let ist = FixedOffset::east_opt(5 * 3600 + 1800).unwrap(); // UTC+5:30
let naive = NaiveDateTime::parse_from_str("2024-06-15 12:00:00", "%Y-%m-%d %H:%M:%S")
.unwrap();
// Always Single for fixed offsets
match est.from_local_datetime(&naive) {
LocalResult::Single(dt) => {
println!("Fixed offset is always unambiguous: {}", dt);
}
_ => unreachable!("Fixed offsets never have ambiguity"),
}
// This is why you can use .single().unwrap() safely for fixed offsets
}Fixed-offset timezones (no DST) never produce ambiguous or non-existent times.
Synthesis
Quick reference:
| Scenario | LocalResult |
Cause | Handling |
|---|---|---|---|
| Normal time | Single |
Unambiguous mapping | Use directly |
| Fall-back (DST end) | Ambiguous |
Local time occurs twice | Choose earliest or latest |
| Spring-forward (DST start) | None |
Local time skipped | Adjust or error |
Ambiguous time example:
use chrono::{TimeZone, NaiveDateTime, LocalResult};
fn handle_ambiguous_time<Tz: TimeZone>(tz: &Tz, naive: &NaiveDateTime) {
match tz.from_local_datetime(naive) {
LocalResult::Ambiguous(earliest, latest) => {
// Example: 1:30 AM on fall-back day
// earliest = 1:30 AM before transition (e.g., 05:30 UTC)
// latest = 1:30 AM after transition (e.g., 06:30 UTC)
// Decision strategies:
// 1. Use earliest: assume time was in summer time
// 2. Use latest: assume time was in standard time
// 3. Ask user: present both options
// 4. Reject: return error and ask for clarification
}
_ => {}
}
}Key insight: The LocalResult enum represents the fundamental challenge of converting local times to UTC: not all local times exist in all timezones, and some exist twice. During DST "spring forward," a gap is created where local times don't exist (e.g., 2:00 AM to 2:59 AM might be skipped). During DST "fall back," some local times occur twice (e.g., 1:00 AM to 1:59 AM happens in both daylight time and standard time). The from_local_datetime method returns LocalResult::None for times that don't exist, LocalResult::Ambiguous for times that occur twice, and LocalResult::Single for unambiguous times. This forces explicit handling at the call site rather than silently picking an interpretation. For applications that need a specific policy, LocalResult provides .earliest(), .latest(), and .single() convenience methods that return Option<DateTime>. Fixed-offset timezones never produce ambiguous or non-existent times since they have no DST transitions—from_local_datetime always returns Single. For real-world timezone handling with accurate DST transitions, use the chrono-tz crate which includes the IANA timezone database.
