Skip to content
ExponaBLOG
APACHE SPARK & DATA ENGINEERING · DAY 043 / 100

Working with dates and timestamps: a step-by-step guide

Time is tricky in data pipelines.

Expona Admin7 min read

Introduction

Time is tricky in data pipelines. When you're aggregating events from multiple regions, backfilling historical data, or debugging off-by-one errors in your analytics, timestamp handling becomes the difference between correct results and silent data corruption. This guide walks you through the practical patterns we've learned while working with dates and timestamps in Apache Spark across production systems handling millions of events daily.

By the end of this article, you'll understand how to handle timestamps consistently across your data pipeline, avoiding the timezone nightmares and timezone-related bugs that plague many teams. We'll cover the core strategies: storing UTC, configuring session timezones, using Spark's date functions effectively, and organizing your data with date-based partitioning.

Store UTC: The foundation of reliable timestamps

The single most important rule for timestamp handling is this: always store timestamps in UTC internally. This applies whether you're persisting to Parquet, Delta, or any other format. UTC gives you a universal reference point that doesn't shift with daylight saving time or regional changes.

When data arrives from client applications, web logs, or third-party APIs, it often comes with local times or mixed timezone information. Your first step is normalization. Here's how we typically handle this in Spark:

python
from pyspark.sql import SparkSession
from pyspark.sql.functions import col, to_timestamp, from_utc_timestamp, to_utc_timestamp

spark = SparkSession.builder.appName("TimestampHandling").getOrCreate()

# Sample data with timestamps in different formats
raw_data = [
    ("user_123", "2024-01-15 14:30:00"),  # Assumed local time
    ("user_456", "2024-01-15 09:45:00"),
]

df = spark.createDataFrame(raw_data, ["user_id", "event_time_str"])

# Step 1: Convert string to timestamp (assumes the string is in a specific timezone)
# Here we assume incoming times are in US/Eastern
df_with_local = df.withColumn(
    "event_time_local",
    to_timestamp(col("event_time_str"), "yyyy-MM-dd HH:mm:ss")
)

# Step 2: Convert local time to UTC
df_with_utc = df_with_local.withColumn(
    "event_time_utc",
    to_utc_timestamp(col("event_time_local"), "US/Eastern")
)

df_with_utc.show()

This pattern ensures that no matter where your data originates, everything lands in the same timezone internally. When you need to display times in a user's local timezone later, you convert from UTC then—but your source of truth remains UTC.

Set the session timezone: Configuration at scale

Spark allows you to configure a default timezone for your entire session. This becomes crucial when you're running scheduled jobs, batch processes, or interactive notebooks where you want consistent behavior across all timestamp operations.

By default, Spark uses the system timezone of the machine running the driver. In a distributed environment, this is unpredictable and dangerous. We always set it explicitly:

python
spark = SparkSession.builder \
    .appName("TimestampHandling") \
    .config("spark.sql.session.timeZone", "UTC") \
    .getOrCreate()

With this configuration, any implicit timezone conversion that Spark performs uses UTC as the reference. This prevents subtle bugs where timestamp functions behave differently depending on where your job runs.

In a real production scenario, we typically handle data arriving with explicit timezone information. Here's a more realistic pattern:

python
from pyspark.sql.functions import col, to_timestamp, from_utc_timestamp

# Data arrives with timezone metadata
events_data = [
    ("user_1", "2024-01-15 14:30:00+05:30"),  # India Standard Time
    ("user_2", "2024-01-15 09:45:00-08:00"),  # US Pacific
]

df = spark.createDataFrame(events_data, ["user_id", "event_time_str"])

# Parse timestamp WITH timezone information
df_parsed = df.withColumn(
    "event_time_utc",
    to_timestamp(col("event_time_str"), "yyyy-MM-dd HH:mm:ssZ")
)

df_parsed.show()
df_parsed.printSchema()

When your incoming data already includes timezone offsets (the Z suffix in ISO 8601 format), Spark handles the conversion automatically and stores the result in UTC internally.

to_date and date_trunc: Operating on temporal data

Once you have clean UTC timestamps, you'll frequently need to truncate them for grouping, comparison, or analysis. The to_date and date_trunc functions serve different purposes and understanding when to use each matters.

to_date converts a timestamp to a date, losing time information. Use this when you need to group by day or when your analysis only cares about the date portion:

python
from pyspark.sql.functions import col, to_date, current_timestamp

# Sample transactions
transactions = [
    ("tx_001", "2024-01-15 14:30:45"),
    ("tx_002", "2024-01-15 23:45:30"),
    ("tx_003", "2024-01-16 01:15:20"),
]

df = spark.createDataFrame(transactions, ["tx_id", "tx_time_str"])

df_with_date = df.withColumn(
    "tx_time", to_timestamp(col("tx_time_str"))
).withColumn(
    "tx_date", to_date(col("tx_time"))
)

df_with_date.show()

# Group transactions by date
daily_volume = df_with_date.groupBy("tx_date").count()
daily_volume.show()

date_trunc is more flexible. It truncates a timestamp to a specified unit—second, minute, hour, day, week, or month—while keeping the result as a timestamp:

python
from pyspark.sql.functions import col, date_trunc, to_timestamp

df_with_time = df.withColumn("tx_time", to_timestamp(col("tx_time_str")))

# Truncate to different levels
df_truncated = df_with_time \
    .withColumn("truncated_hour", date_trunc("hour", col("tx_time"))) \
    .withColumn("truncated_day", date_trunc("day", col("tx_time"))) \
    .withColumn("truncated_week", date_trunc("week", col("tx_time"))) \
    .withColumn("truncated_month", date_trunc("month", col("tx_time")))

df_truncated.show()

Use date_trunc when you need the result to remain a timestamp for further calculations, or when you need sub-day precision (hourly grouping, for example). Use to_date when you explicitly want to work with dates and don't need timestamp operations.

Partition by event date: Structuring data for performance

Date-based partitioning is how we organize large datasets for both performance and operational sanity. When you partition a Parquet or Delta table by date, Spark can prune partitions during query execution, dramatically reducing I/O and processing time.

Here's a practical example building on our transaction scenario:

python
from pyspark.sql.functions import col, to_date, to_timestamp

# Create a larger dataset
import datetime
import random

data = []
for days_ago in range(30):
    for _ in range(100):
        ts = datetime.datetime.utcnow() - datetime.timedelta(days=days_ago)
        ts = ts.replace(hour=random.randint(0, 23), minute=random.randint(0, 59))
        data.append((f"user_{random.randint(1, 1000)}", ts.isoformat()))

df = spark.createDataFrame(data, ["user_id", "event_time_str"])

df_with_date = df.withColumn(
    "event_time", to_timestamp(col("event_time_str"))
).withColumn(
    "event_date", to_date(col("event_time"))
)

# Write with date-based partitioning
df_with_date.write \
    .mode("overwrite") \
    .partitionBy("event_date") \
    .parquet("/data/events")

When written this way, your data on disk is organized as:

python
/data/events/event_date=2024-01-01/
/data/events/event_date=2024-01-02/
/data/events/event_date=2024-01-03/

Now, when you query for a specific date range, Spark skips entire directories:

python
# This query only reads the partitions for Jan 15-17
df_read = spark.read.parquet("/data/events")
result = df_read.filter(
    (col("event_date") >= "2024-01-15") & 
    (col("event_date") <= "2024-01-17")
).count()

For high-volume pipelines processing millions of events, partitioning by date can reduce query time from minutes to seconds. The key is ensuring your partition column contains dates or properly formatted timestamps that Spark can use for pruning.

Common mistakes

  • Mixing timezones without normalization. Storing timestamps in multiple timezones makes joins, comparisons, and aggregations unreliable. Always normalize to UTC immediately on ingestion.
  • Assuming system timezone is UTC. Never rely on the machine's local timezone. Always set spark.sql.session.timeZone explicitly, even when you think it's already UTC.
  • Using string timestamps for filtering or comparison. String comparisons work for ISO 8601 format, but they're fragile and don't account for timezone logic. Parse to timestamp first, then filter.
  • Partitioning by timestamp instead of date. Partition columns should have reasonable cardinality. Partitioning by a microsecond-precision timestamp creates millions of tiny partitions. Truncate to date or hour instead.
  • Ignoring daylight saving time transitions. When converting between timezones, DST changes can shift times by an hour. Use Spark's timezone-aware functions; don't try to handle DST manually.
  • Forgetting to persist converted timestamps. Converting a timestamp in a SELECT but not storing it means the next job sees the original data again. Always materialize conversions when you need consistency across processes.

Key takeaways

  • Store all timestamps in UTC internally—it's your single source of truth and eliminates timezone drift across your pipeline.
  • Set spark.sql.session.timeZone to UTC in your SparkSession configuration to ensure consistent behavior in production jobs.
  • Use to_date for simple date extraction and date_trunc when you need timestamp-level precision or different truncation units.
  • Partition large datasets by date using partitionBy("event_date") to enable partition pruning and dramatically improve query performance.
  • Parse incoming timestamps with timezone information using ISO 8601 format with the Z suffix, letting Spark handle the conversion automatically.
  • Always normalize timestamps at the entry point of your pipeline, not scattered throughout your transformations.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles