put Timezone: UTC (explicit declaration prevents silent drift)
Why this works: Luxon's formatting tokens are strict. Using ZZ forces an offset representation, making the output unambiguous for downstream systems. Explicitly setting the input timezone ensures that timestamps lacking a Z or +00:00 suffix are interpreted correctly rather than defaulting to the workflow's execution environment.
Step 2: Calculate Temporal Differences for SLA Tracking
Business rules often require measuring elapsed time against thresholds. Integer-based units simplify conditional routing.
Configuration:
- Operation:
Calculate a Date Difference
- Start Date:
{{ $json.created_at }}
- End Date:
{{ $now.toISO() }}
- Units:
hours
- Output Field:
sla_elapsed_hours
Architecture Rationale: Always calculate differences in the smallest unit required for comparison. If your SLA threshold is 48 hours, request hours directly. Requesting days introduces floating-point rounding that complicates IF node conditions. Integer outputs align cleanly with n8n's numeric comparison operators.
Step 3: Apply Temporal Offsets for Expiry Windows
Adding or subtracting durations requires chaining when human-readable output is needed. The arithmetic operation returns a Luxon DateTime object, not a formatted string.
Configuration (Node A - Arithmetic):
- Operation:
Add to a Date
- Base Date:
{{ $now.toISO() }}
- Duration:
30
- Unit:
days
- Output Field:
expiry_raw
Configuration (Node B - Formatting):
- Operation:
Format a Date
- Input Field:
{{ $json.expiry_raw }}
- Format String:
MMMM d, yyyy
- Output Field:
expiry_display
Why chain instead of combine: The DateTime node separates arithmetic from presentation. Attempting to format within the same operation breaks the immutable Luxon object pipeline. Chaining two nodes creates a clear data lineage: raw calculation → presentation layer. This pattern also enables reuse; the same expiry_raw field can feed multiple downstream nodes with different format requirements.
Sometimes only a temporal fragment matters. Extracting parts avoids full string parsing and keeps conditional logic lightweight.
Configuration:
- Operation:
Extract Part of a Date
- Input Field:
{{ $now.toISO() }}
- Part:
hour
- Output Field:
routing_hour
- Input Timezone:
America/Chicago
Expression Alternative for Complex Extraction:
When the node's built-in parts don't match your needs, leverage the expression engine directly:
// Extract ISO weekday (1=Monday, 7=Sunday)
{{ DateTime.fromISO($json.event_timestamp).setZone('America/Chicago').weekday }}
// Check if timestamp falls within business hours
{{ DateTime.fromISO($json.event_timestamp).setZone('America/Chicago').hour >= 9 && DateTime.fromISO($json.event_timestamp).setZone('America/Chicago').hour < 17 }}
Architecture Decision: Use the DateTime node for field persistence and the expression engine for inline conditions. Expressions evaluate at runtime without creating intermediate fields, reducing payload size. Reserve the node for values that multiple downstream steps consume.
Pitfall Guide
Temporal operations fail silently when assumptions about data types, timezones, or library behavior go unvalidated. The following pitfalls represent the most common production failures observed in workflow automation.
1. Silent UTC Assumption on Unqualified Timestamps
Explanation: n8n treats incoming strings without a timezone suffix or offset as UTC. If your source system emits local time (e.g., 2026-07-02 10:25:00), the engine will shift it to UTC, causing downstream calculations to drift by the local offset.
Fix: Always populate the Input Timezone field when parsing unqualified strings. For global systems, standardize on UTC at ingestion and convert to local zones only at presentation.
2. Type Coercion Failures Between Strings and Epoch Integers
Explanation: The DateTime node auto-detects numeric inputs as Unix milliseconds. If an upstream API returns seconds (e.g., 1751453100), n8n will interpret it as a date in 1970, breaking all downstream logic.
Fix: Validate upstream payloads. If seconds are expected, multiply by 1000 in a Set node before the DateTime operation, or use an expression: {{ DateTime.fromSeconds($json.epoch_ts).toISO() }}.
3. Semantic Mismatch Between $today and $now
Explanation: $today resolves to midnight UTC of the current calendar day. $now resolves to the exact execution instant. Using $today for timestamp comparisons truncates time components, causing false negatives in SLA or routing logic.
Fix: Reserve $today for date-only comparisons (e.g., "expires on this calendar day"). Use $now for all timestamp arithmetic, difference calculations, and real-time routing.
4. Daylight Saving Transition Blind Spots
Explanation: Adding fixed durations across DST boundaries can produce unexpected results. Adding 24 hours during a spring-forward transition may skip an hour, while fall-back transitions may duplicate it. Luxon handles this correctly when zones are explicit, but implicit UTC arithmetic ignores DST entirely.
Fix: Always specify a named timezone (America/New_York, Europe/London) in the Input Timezone field. Avoid adding raw hours across DST boundaries; use days or weeks when calendar alignment matters more than exact hour counts.
Explanation: Format tokens like MMMM or EEE output month and weekday names in English by default. If your workflow serves international users, hardcoded English names break localization requirements.
Fix: Use expression-level locale switching: {{ DateTime.fromISO($json.event_timestamp).setLocale('de').toFormat('MMMM d, yyyy') }}. For multi-tenant systems, store the target locale in a user profile field and pass it dynamically.
Explanation: Attempting to format a date and perform arithmetic in a single node configuration is unsupported. The DateTime node returns either a formatted string or a Luxon object, never both simultaneously.
Fix: Adopt the two-node pipeline pattern. Node 1 performs arithmetic and outputs a raw DateTime object. Node 2 consumes that object and applies formatting. This separation enables parallel branching and reduces coupling.
7. Floating-Point Drift in SLA Thresholds
Explanation: Calculating differences in days returns decimals (e.g., 2.75). Using these values in IF node conditions without rounding causes unpredictable branching when thresholds are integers.
Fix: Request the difference in the smallest unit required for comparison (hours or minutes). If days are mandatory, wrap the output in a rounding expression: {{ Math.round($json.days_elapsed) }}.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
Simple inline comparison (e.g., expires < now) | Expression Engine | No intermediate fields needed; evaluates at runtime | Zero overhead |
| SLA calculation with threshold routing | DateTime Node (Difference) + IF Node | Integer output aligns with numeric conditions; visual traceability | Low compute, high maintainability |
| Multi-tenant localized formatting | Expression Engine with .setLocale() | Dynamic locale switching without node duplication | Minimal, scales with tenant count |
| Complex calendar math (business days, holidays) | Custom Code Node | Native node lacks holiday calendars; requires external library | Higher dev cost, necessary for compliance |
| High-volume batch processing (10k+ rows) | DateTime Node (batch mode) | Optimized Luxon bindings outperform per-row JS execution | Lower runtime cost, faster throughput |
Configuration Template
Copy this structure into your n8n workflow JSON or use it as a reference for manual node creation. This template implements a reusable SLA monitoring pattern.
{
"name": "SLA Temporal Processor",
"nodes": [
{
"parameters": {
"operation": "dateDiff",
"date": "={{ $json.created_at }}",
"date2": "={{ $now.toISO() }}",
"outputFieldName": "sla_elapsed_hours",
"options": { "unit": "hours" }
},
"name": "Calculate SLA Elapsed",
"type": "n8n-nodes-base.dateTime",
"typeVersion": 2,
"position": [200, 0]
},
{
"parameters": {
"operation": "format",
"date": "={{ $json.created_at }}",
"format": "yyyy-MM-dd'T'HH:mm:ssZZ",
"outputFieldName": "normalized_created",
"options": { "inputTimezone": "UTC" }
},
"name": "Normalize Timestamp",
"type": "n8n-nodes-base.dateTime",
"typeVersion": 2,
"position": [200, 200]
},
{
"parameters": {
"conditions": {
"number": [
{
"value1": "={{ $json.sla_elapsed_hours }}",
"operation": "larger",
"value2": "={{ $env.SLA_THRESHOLD_HOURS }}"
}
]
}
},
"name": "Check SLA Breach",
"type": "n8n-nodes-base.filter",
"typeVersion": 1,
"position": [400, 0]
}
],
"connections": {
"Calculate SLA Elapsed": { "main": [[{ "node": "Check SLA Breach", "type": "main", "index": 0 }]] },
"Normalize Timestamp": { "main": [[{ "node": "Check SLA Breach", "type": "main", "index": 0 }]] }
}
}
Quick Start Guide
- Add the DateTime node to your workflow canvas and select
Format a Date as the operation.
- Map your input field to the raw timestamp from the previous node (e.g.,
{{ $json.event_timestamp }}).
- Set the Input Timezone explicitly to match your source system (e.g.,
UTC, America/New_York).
- Choose a format token from the dropdown or enter a custom string like
yyyy-MM-dd HH:mm:ss.
- Define the Output Field Name (e.g.,
formatted_event) and run a test execution to verify the parsed result matches expectations.