Temporal Data and Time-Series in Geode

Temporal data management is essential for tracking when events occur, how entities change over time, and analyzing time-based patterns. Geode provides comprehensive support for temporal data types, functions, and query patterns aligned with the ISO/IEC 39075:2024 GQL standard.

Whether you are building audit systems, tracking user activity, analyzing time-series data, or implementing bi-temporal data models, Geode offers the primitives and query capabilities to handle temporal requirements effectively.

Temporal Data Types

Geode supports the following temporal types:

DATE

Represents a calendar date without time information.

-- Create nodes with DATE properties
CREATE (event:Event {
    id: 'evt_001',
    name: 'Product Launch',
    event_date: DATE '2025-03-15'
});

-- Date from components
CREATE (holiday:Holiday {
    name: 'Independence Day',
    date: date(2025, 7, 4)
});

-- Current date
CREATE (log:AuditLog {
    action: 'USER_CREATED',
    log_date: current_date()
});

TIME

Represents time of day without date information.

-- Store time values
CREATE (schedule:Schedule {
    store_id: 'store_001',
    opens_at: TIME '09:00:00',
    closes_at: TIME '21:00:00'
});

-- Time with timezone
CREATE (meeting:Meeting {
    title: 'Team Standup',
    scheduled_time: TIME '10:30:00-05:00'  -- EST
});

-- Current time
CREATE (entry:TimeEntry {
    clock_in: current_time()
});

DATETIME / TIMESTAMP

Represents a specific point in time with date, time, and optional timezone.

-- Full datetime with timezone
CREATE (order:Order {
    id: 'order_001',
    placed_at: datetime('2025-01-15T14:30:00Z'),
    status: 'pending'
});

-- Datetime from components
CREATE (appointment:Appointment {
    datetime: datetime(2025, 6, 15, 10, 30, 0)
});

-- Current timestamp
CREATE (session:Session {
    user_id: 'user_001',
    started_at: datetime(),
    last_activity: datetime()
});

-- Parse from ISO 8601 string
MATCH (o:Order {id: 'order_001'})
SET o.shipped_at = datetime('2025-01-16T09:00:00-08:00');

DURATION

Represents a time span or interval.

-- Duration literals
CREATE (subscription:Subscription {
    plan: 'annual',
    duration: DURATION 'P1Y',  -- 1 year
    created_at: datetime()
});

-- Duration components
-- P = period, T = time separator
-- Y=years, M=months, D=days, H=hours, M=minutes, S=seconds
CREATE (trial:Trial {
    name: 'Free Trial',
    length: DURATION 'P14D'  -- 14 days
});

CREATE (timeout:Config {
    name: 'session_timeout',
    value: DURATION 'PT30M'  -- 30 minutes
});

-- Duration arithmetic
MATCH (s:Subscription {plan: 'annual'})
SET s.expires_at = s.created_at + s.duration;

Temporal Functions

Date/Time Component Extraction

-- Extract components from datetime
MATCH (o:Order)
RETURN o.id,
       year(o.placed_at) AS order_year,
       month(o.placed_at) AS order_month,
       day(o.placed_at) AS order_day,
       hour(o.placed_at) AS order_hour,
       minute(o.placed_at) AS order_minute,
       second(o.placed_at) AS order_second;

-- Day of week (1=Monday, 7=Sunday)
MATCH (e:Event)
RETURN e.name,
       dayOfWeek(e.event_date) AS weekday,
       dayOfYear(e.event_date) AS day_of_year;

-- Week and quarter
MATCH (s:Sale)
RETURN week(s.sale_date) AS week_number,
       quarter(s.sale_date) AS quarter;

Date/Time Arithmetic

-- Add duration to datetime
MATCH (s:Subscription)
SET s.renewal_date = s.start_date + DURATION 'P1Y';

-- Subtract duration
MATCH (u:User)
WHERE u.last_login < datetime() - DURATION 'P30D'
SET u.status = 'inactive';

-- Difference between dates (returns duration)
MATCH (o:Order)
WHERE o.delivered_at IS NOT NULL
RETURN o.id,
       o.delivered_at - o.placed_at AS delivery_time;

-- Add specific time units
MATCH (t:Task)
SET t.due_date = t.created_at + DURATION 'P7D';  -- 7 days later

Truncation and Rounding

-- Truncate to start of period
MATCH (e:Event)
RETURN e.name,
       date_trunc('month', e.event_date) AS month_start,
       date_trunc('year', e.event_date) AS year_start,
       date_trunc('week', e.event_date) AS week_start;

-- Truncate datetime
MATCH (l:LoginEvent)
RETURN date_trunc('hour', l.timestamp) AS hour,
       COUNT(*) AS login_count
ORDER BY hour;

-- Round to nearest interval
MATCH (m:Measurement)
RETURN date_trunc('minute', m.recorded_at) AS minute,
       AVG(m.value) AS avg_value
ORDER BY minute;

Formatting and Parsing

-- Format datetime as string
MATCH (o:Order)
RETURN o.id,
       format_datetime(o.placed_at, 'YYYY-MM-DD') AS date_str,
       format_datetime(o.placed_at, 'YYYY-MM-DD HH:mm:ss') AS full_str;

-- Parse string to datetime
MATCH (import:ImportRecord)
SET import.parsed_date = parse_datetime(import.date_string, 'MM/DD/YYYY');

Time-Based Query Patterns

Range Queries

-- Events within date range
MATCH (e:Event)
WHERE e.event_date >= DATE '2025-01-01'
  AND e.event_date < DATE '2025-04-01'
RETURN e.name, e.event_date
ORDER BY e.event_date;

-- Orders in last 30 days
MATCH (o:Order)
WHERE o.placed_at >= datetime() - DURATION 'P30D'
RETURN o.id, o.total, o.placed_at;

-- Activity within time window
MATCH (s:Session)
WHERE s.started_at >= datetime() - DURATION 'PT1H'
RETURN COUNT(s) AS active_sessions;

Time-Based Aggregations

-- Daily order totals
MATCH (o:Order)
WHERE o.placed_at >= datetime() - DURATION 'P7D'
RETURN date(o.placed_at) AS order_date,
       COUNT(o) AS order_count,
       SUM(o.total) AS daily_revenue
ORDER BY order_date;

-- Hourly traffic analysis
MATCH (v:PageView)
WHERE v.timestamp >= datetime() - DURATION 'P1D'
RETURN date_trunc('hour', v.timestamp) AS hour,
       COUNT(v) AS views
ORDER BY hour;

-- Monthly user signups
MATCH (u:User)
WHERE u.created_at >= datetime() - DURATION 'P1Y'
RETURN year(u.created_at) AS year,
       month(u.created_at) AS month,
       COUNT(u) AS new_users
ORDER BY year, month;

-- Weekly active users
MATCH (u:User)-[:PERFORMED]->(a:Activity)
WHERE a.timestamp >= datetime() - DURATION 'P7D'
RETURN COUNT(DISTINCT u) AS weekly_active_users;

Period Comparisons

-- Compare current period to previous
WITH datetime() AS now,
     datetime() - DURATION 'P7D' AS week_ago,
     datetime() - DURATION 'P14D' AS two_weeks_ago

MATCH (o:Order)
WHERE o.placed_at >= two_weeks_ago

WITH now, week_ago, two_weeks_ago,
     SUM(CASE WHEN o.placed_at >= week_ago THEN o.total ELSE 0 END) AS current_week,
     SUM(CASE WHEN o.placed_at < week_ago THEN o.total ELSE 0 END) AS previous_week

RETURN current_week,
       previous_week,
       (current_week - previous_week) / previous_week * 100 AS percent_change;

-- Year-over-year comparison
MATCH (s:Sale)
WHERE year(s.sale_date) IN [2024, 2025]
  AND month(s.sale_date) = 1
RETURN year(s.sale_date) AS year,
       SUM(s.amount) AS january_sales
ORDER BY year;

Temporal Data Modeling Patterns

Event Timestamps

Track when events occur with immutable timestamps:

-- Audit trail pattern
CREATE (al:AuditLog {
    id: randomUUID(),
    entity_type: 'User',
    entity_id: $user_id,
    action: 'UPDATE',
    changes: $change_json,
    performed_by: $actor_id,
    performed_at: datetime()
});

-- Query audit history
MATCH (al:AuditLog {entity_type: 'User', entity_id: $user_id})
RETURN al.action, al.changes, al.performed_by, al.performed_at
ORDER BY al.performed_at DESC
LIMIT 50;

Temporal Relationships

Add time bounds to relationships:

-- Employment with date range
CREATE (person)-[:EMPLOYED_AT {
    role: 'Software Engineer',
    start_date: DATE '2022-03-15',
    end_date: null  -- Current position
}]->(company);

-- Query current employment
MATCH (p:Person)-[e:EMPLOYED_AT]->(c:Company)
WHERE e.end_date IS NULL
  OR e.end_date > current_date()
RETURN p.name, c.name, e.role, e.start_date;

-- Query employment at specific date
MATCH (p:Person)-[e:EMPLOYED_AT]->(c:Company)
WHERE e.start_date <= $target_date
  AND (e.end_date IS NULL OR e.end_date >= $target_date)
RETURN p.name, c.name, e.role;

-- Employment history
MATCH (p:Person {id: $person_id})-[e:EMPLOYED_AT]->(c:Company)
RETURN c.name, e.role, e.start_date, e.end_date
ORDER BY e.start_date DESC;

Bi-Temporal Modeling

Track both valid time (when true in reality) and transaction time (when recorded):

-- Bi-temporal price record
CREATE (price:PriceRecord {
    product_id: 'prod_001',
    amount: 29.99,

    -- Valid time: when this price is/was effective
    valid_from: DATE '2025-01-01',
    valid_to: DATE '2025-06-30',

    -- Transaction time: when recorded in system
    recorded_at: datetime(),
    superseded_at: null  -- Current record
});

-- Query current price (valid now, current record)
MATCH (pr:PriceRecord {product_id: $product_id})
WHERE pr.valid_from <= current_date()
  AND (pr.valid_to IS NULL OR pr.valid_to > current_date())
  AND pr.superseded_at IS NULL
RETURN pr.amount AS current_price;

-- Query price at past point in time
MATCH (pr:PriceRecord {product_id: $product_id})
WHERE pr.valid_from <= $query_date
  AND (pr.valid_to IS NULL OR pr.valid_to > $query_date)
  AND pr.recorded_at <= $as_of_time
  AND (pr.superseded_at IS NULL OR pr.superseded_at > $as_of_time)
RETURN pr.amount AS price_at_time;

-- Update price (create new record, supersede old)
MATCH (old:PriceRecord {product_id: $product_id})
WHERE old.superseded_at IS NULL
SET old.superseded_at = datetime();

CREATE (new:PriceRecord {
    product_id: $product_id,
    amount: $new_price,
    valid_from: $effective_date,
    valid_to: null,
    recorded_at: datetime(),
    superseded_at: null
});

Time-Series Data

Model time-series measurements:

-- Sensor readings
CREATE (reading:SensorReading {
    sensor_id: 'sensor_001',
    timestamp: datetime(),
    temperature: 23.5,
    humidity: 45.2,
    pressure: 1013.25
});

-- Link readings to sensor
MATCH (s:Sensor {id: 'sensor_001'})
CREATE (r:SensorReading {
    timestamp: datetime(),
    temperature: $temp,
    humidity: $humidity
})
CREATE (s)-[:RECORDED]->(r);

-- Query time-series data
MATCH (s:Sensor {id: $sensor_id})-[:RECORDED]->(r:SensorReading)
WHERE r.timestamp >= datetime() - DURATION 'PT24H'
RETURN r.timestamp, r.temperature, r.humidity
ORDER BY r.timestamp;

-- Downsampled aggregation (hourly averages)
MATCH (s:Sensor {id: $sensor_id})-[:RECORDED]->(r:SensorReading)
WHERE r.timestamp >= datetime() - DURATION 'P7D'
RETURN date_trunc('hour', r.timestamp) AS hour,
       AVG(r.temperature) AS avg_temp,
       MIN(r.temperature) AS min_temp,
       MAX(r.temperature) AS max_temp,
       COUNT(r) AS sample_count
ORDER BY hour;

Event Sourcing

Store events with timestamps for complete history:

-- Create domain events
CREATE (evt:OrderEvent {
    order_id: 'order_001',
    event_type: 'ORDER_PLACED',
    timestamp: datetime(),
    data: $event_data,
    sequence: 1
});

-- Append new event
MATCH (prev:OrderEvent {order_id: $order_id})
WHERE NOT EXISTS { (prev)-[:FOLLOWED_BY]->() }
CREATE (new:OrderEvent {
    order_id: $order_id,
    event_type: $event_type,
    timestamp: datetime(),
    data: $data,
    sequence: prev.sequence + 1
})
CREATE (prev)-[:FOLLOWED_BY]->(new);

-- Replay events in order
MATCH (e:OrderEvent {order_id: $order_id})
RETURN e.event_type, e.timestamp, e.data
ORDER BY e.sequence;

-- Get latest event
MATCH (e:OrderEvent {order_id: $order_id})
WHERE NOT EXISTS { (e)-[:FOLLOWED_BY]->() }
RETURN e.event_type AS current_status, e.timestamp;

Timezone Handling

Working with Timezones

-- Store with explicit timezone
CREATE (meeting:Meeting {
    title: 'Global Standup',
    scheduled_at: datetime('2025-03-15T10:00:00-05:00')  -- EST
});

-- Convert timezone
MATCH (m:Meeting {title: 'Global Standup'})
RETURN m.title,
       m.scheduled_at AS est_time,
       m.scheduled_at AT TIME ZONE 'UTC' AS utc_time,
       m.scheduled_at AT TIME ZONE 'America/Los_Angeles' AS pst_time;

-- Store as UTC, display in local
CREATE (event:Event {
    name: 'Product Launch',
    utc_time: datetime('2025-06-01T18:00:00Z')
});

MATCH (e:Event {name: 'Product Launch'})
RETURN e.name,
       e.utc_time AT TIME ZONE $user_timezone AS local_time;

Best Practices for Timezones

-- RECOMMENDED: Store all times in UTC
CREATE (order:Order {
    id: $id,
    placed_at: datetime()  -- Server time should be UTC
});

-- Convert to user's timezone for display
MATCH (o:Order {id: $id})
RETURN o.placed_at AT TIME ZONE $display_timezone AS display_time;

-- For date-specific queries, be aware of timezone boundaries
MATCH (o:Order)
WHERE date(o.placed_at AT TIME ZONE $user_tz) = $target_date
RETURN o.id;

Performance Optimization

Indexing Temporal Properties

-- Index frequently queried temporal columns
CREATE INDEX idx_order_placed ON Order(placed_at);
CREATE INDEX idx_event_date ON Event(event_date);
CREATE INDEX idx_session_started ON Session(started_at);

-- Composite index for entity + time queries
CREATE INDEX idx_user_activity ON Activity(user_id, timestamp);

-- Use indexes in range queries
MATCH (o:Order)
WHERE o.placed_at >= $start_date AND o.placed_at < $end_date
RETURN o.id, o.total;  -- Uses idx_order_placed

Partitioning Strategies

-- Add partition key to temporal data
CREATE (log:AuditLog {
    entity_id: $id,
    action: $action,
    timestamp: datetime(),
    partition_key: format_datetime(datetime(), 'YYYY-MM')  -- Monthly
});

-- Query within partition
MATCH (log:AuditLog {partition_key: '2025-01'})
WHERE log.entity_id = $entity_id
RETURN log.action, log.timestamp;

Retention and Archival

-- Archive old data
MATCH (log:AuditLog)
WHERE log.timestamp < datetime() - DURATION 'P1Y'
SET log:ArchivedAuditLog
REMOVE log:AuditLog;

-- Delete truly old data
MATCH (log:ArchivedAuditLog)
WHERE log.timestamp < datetime() - DURATION 'P7Y'
DELETE log;

-- Aggregate before archiving
MATCH (reading:SensorReading)
WHERE reading.timestamp < datetime() - DURATION 'P30D'
WITH date(reading.timestamp) AS day,
     reading.sensor_id AS sensor,
     AVG(reading.value) AS avg_value,
     MIN(reading.value) AS min_value,
     MAX(reading.value) AS max_value,
     COUNT(reading) AS sample_count
CREATE (summary:DailySensorSummary {
    sensor_id: sensor,
    date: day,
    avg_value: avg_value,
    min_value: min_value,
    max_value: max_value,
    sample_count: sample_count
})
WITH reading
DELETE reading;

Client Library Examples

Python Client

from geode_client import Client
from datetime import datetime, timedelta, date

async def temporal_operations():
    client = Client(host="localhost", port=3141)

    async with client.connection() as conn:
        # Create event with timestamp
        await conn.execute("""
            CREATE (e:Event {
                id: $id,
                name: $name,
                event_date: $event_date,
                created_at: datetime()
            })
        """, {
            'id': 'evt_001',
            'name': 'Conference',
            'event_date': date(2025, 6, 15)
        })

        # Query events in date range
        start_date = date(2025, 6, 1)
        end_date = date(2025, 6, 30)

        result, _ = await conn.query("""
            MATCH (e:Event)
            WHERE e.event_date >= $start AND e.event_date <= $end
            RETURN e.name, e.event_date
            ORDER BY e.event_date
        """, {'start': start_date, 'end': end_date})

        for row in result.rows:
            print(f"Event: {row['e.name']} on {row['e.event_date']}")

        # Time-based aggregation
        result, _ = await conn.query("""
            MATCH (o:Order)
            WHERE o.placed_at >= datetime() - DURATION 'P30D'
            RETURN date(o.placed_at) AS order_date,
                   SUM(o.total) AS daily_total
            ORDER BY order_date
        """)

        for row in result.rows:
            print(f"{row['order_date']}: ${row['daily_total']:.2f}")

Go Client

package main

import (
    "context"
    "database/sql"
    "log"
    "time"

    _ "geodedb.com/geode"
)

func temporalQueries(ctx context.Context, db *sql.DB) error {
    // Insert with timestamp
    _, err := db.ExecContext(ctx, `
        CREATE (log:AuditLog {
            action: $1,
            entity_id: $2,
            timestamp: datetime($3),
            user_id: $4
        })
    `, "UPDATE", "user_123", time.Now().UTC().Format(time.RFC3339), "admin")
    if err != nil {
        return err
    }

    // Query recent activity
    cutoff := time.Now().Add(-24 * time.Hour)
    rows, err := db.QueryContext(ctx, `
        MATCH (log:AuditLog)
        WHERE log.timestamp >= datetime($1)
        RETURN log.action, log.entity_id, log.timestamp
        ORDER BY log.timestamp DESC
    `, cutoff.Format(time.RFC3339))
    if err != nil {
        return err
    }
    defer rows.Close()

    for rows.Next() {
        var action, entityID string
        var timestamp time.Time
        rows.Scan(&action, &entityID, &timestamp)
        log.Printf("%s: %s at %v", action, entityID, timestamp)
    }

    return nil
}

Rust Client

use geode_client::{Client, Value};
use chrono::{Utc, Duration, NaiveDate};

async fn temporal_analytics(client: &Client) -> Result<(), Box<dyn std::error::Error>> {
    // Create time-series data
    let now = Utc::now();
    client.execute(
        r#"
        CREATE (r:Reading {
            sensor_id: $sensor_id,
            timestamp: datetime($timestamp),
            value: $value
        })
        "#,
        &[
            ("sensor_id", Value::String("temp_001".into())),
            ("timestamp", Value::String(now.to_rfc3339())),
            ("value", Value::Float(23.5)),
        ],
    ).await?;

    // Query hourly averages for past week
    let week_ago = (now - Duration::days(7)).to_rfc3339();
    let results = client.query(
        r#"
        MATCH (r:Reading {sensor_id: $sensor_id})
        WHERE r.timestamp >= datetime($since)
        RETURN date_trunc('hour', r.timestamp) AS hour,
               AVG(r.value) AS avg_value,
               COUNT(r) AS samples
        ORDER BY hour
        "#,
        &[
            ("sensor_id", Value::String("temp_001".into())),
            ("since", Value::String(week_ago)),
        ],
    ).await?;

    for row in results.rows() {
        println!(
            "Hour: {:?}, Avg: {:.2}, Samples: {}",
            row.get::<String>("hour")?,
            row.get::<f64>("avg_value")?,
            row.get::<i64>("samples")?
        );
    }

    Ok(())
}

Best Practices

Timestamp Conventions

  • Store all timestamps in UTC
  • Use datetime() for current timestamp (returns UTC)
  • Convert to local timezone only for display
  • Include timezone in datetime literals when not UTC

Data Modeling

  • Add created_at/updated_at to all entities
  • Use immutable timestamps for audit events
  • Consider bi-temporal for regulated domains
  • Partition time-series data by time period

Query Optimization

  • Index temporal properties used in WHERE clauses
  • Use date_trunc for aggregations to leverage indexes
  • Limit time ranges in queries
  • Consider pre-aggregating historical data

Retention

  • Define retention policies early
  • Archive before delete for compliance
  • Aggregate old data to summaries
  • Monitor storage growth

Further Reading

  • ISO/IEC 39075:2024 - Temporal Data Types
  • Time-Series Data Best Practices
  • Bi-Temporal Data Modeling Guide
  • Geode Performance Tuning for Temporal Queries
  • Data Retention and Archival Strategies

Browse tagged content for complete temporal data documentation and examples.


Related Articles

No articles found with this tag yet.

Back to Home