Dates, Time Zones, and the mm That Meant Minutes

DataWeave's temporal types, format-schema parsing and formatting, Period arithmetic, and the timezone shifts and coercion bugs that bite everyone once.

Every order feed you’ll ever touch has a timestamp on it, and half of them will be in a format nobody agreed to. A date is where clean integrations go to die: one feed sends 2026-06-16, the next sends 16/06/2026 09:15, a third sends an epoch millisecond count, and somewhere a customer in Los Angeles insists their order was placed yesterday. DataWeave has genuinely good tools for this — real temporal types, not strings you squint at. But you have to know which type you’re holding and which format token means what.

Seven types, not one

DataWeave doesn’t have “a date.” It has a small family of temporal types, and the difference between them is entirely about what information they carry — a calendar day, a wall-clock time, an instant with a zone attached. Each has a literal form written between pipes:

%dw 2.0
output application/json
---
{
  date:          |2026-06-16|,                // Date         — a calendar day
  localTime:     |14:30:00|,                  // LocalTime    — wall clock, no zone
  time:          |14:30:00Z|,                 // Time         — clock time with a zone
  localDateTime: |2026-06-16T14:30:00|,       // LocalDateTime — day + time, no zone
  dateTime:      |2026-06-16T14:30:00-04:00|, // DateTime     — an instant, zone-aware
  zone:          |-04:00|,                    // TimeZone
  period:        |P1Y2M10D|                   // Period       — a span, not a point
}

The one distinction to burn into memory: DateTime carries a zone, LocalDateTime does not. A DateTime names an actual instant on the timeline; a LocalDateTime is “14:30 somewhere.” Most order-placement timestamps should be DateTime — the moment is unambiguous. The moment you drop to LocalDateTime, you’ve thrown away the offset, and no later code can put it back.

Period is the odd one out — it’s not a point in time but a duration: |P2D| is two days, |PT6H| is six hours, |P1Y2M10D| is a year, two months, and ten days. You add and subtract periods to move dates around.

Reaching into a date

Any temporal value exposes its components as selectors, and they all return Number:

%dw 2.0
output application/json
var placedAt = |2026-06-16T14:30:00-04:00|
---
{
  year:    placedAt.year,       // 2026
  month:   placedAt.month,      // 6
  day:     placedAt.day,        // 16
  hour:    placedAt.hour,       // 14
  minute:  placedAt.minutes,    // 30  — note the plural
  weekday: placedAt.dayOfWeek,  // 2   — ISO: Monday=1 … Sunday=7, so Tuesday
  zone:    placedAt.timezone    // |-04:00|
}

The plurals catch people out — it’s .minutes and .seconds but .hour and .day. And .dayOfWeek is ISO-numbered, so Monday is 1 and Sunday is 7, which is not what a US calendar app would tell you.

Parsing and formatting with format schemas

Coercion with as is how you cross between strings and dates, and the format schema — the {format: "…"} block — is how you spell out the shape when it isn’t standard ISO-8601:

%dw 2.0
output application/json
---
{
  // string -> Date. Non-ISO input MUST declare its format
  parsed:  "16/06/2026" as Date {format: "dd/MM/yyyy"},   // |2026-06-16|
  // Date -> string, formatted however the downstream wants it
  display: |2026-06-16| as String {format: "dd MMM yyyy"}, // "16 Jun 2026"
  // no schema needed either way when the string is already ISO-8601
  iso:     |2026-06-16| as String                          // "2026-06-16"
}

An ISO-8601 string coerces to a date with no schema at all — "2026-06-16T14:30:00Z" as DateTime just works. Anything else needs the {format: …}, and the format tokens are the Java DateTimeFormatter set: yyyy year, MM month, dd day, HH 24-hour, hh 12-hour, mm minutes, ss seconds. Which brings us to the bug in the title.

The mm that meant minutes

MM is month. mm is minutes. They are one shift key apart and they will both parse without complaint, because 08 is a perfectly valid minute and a perfectly valid month. Write yyyy-mm-dd when you meant yyyy-MM-dd and DataWeave will happily read the month field as minutes, hand you a garbage date, and never raise a finger. This is the single most common date bug in DataWeave, and it survives review because the code looks right.

A short list of the other three that get everyone once:

  • A non-ISO string with no {format}. "06/16/2026" as Date fails outright — there’s no format to guess from, and DataWeave won’t invent one.
  • Coercing DateTime to LocalDateTime. It succeeds, silently drops the offset, and now >> and any cross-zone comparison downstream is meaningless.
  • hh where you wanted HH. Twelve-hour parsing without an AM/PM marker (a) turns 14:00 into a puzzle the parser can’t solve — or worse, quietly solves wrong.

Moving dates with Periods

You don’t add numbers to dates — you add Periods. Arithmetic is + and - with a Period on the right, and DataWeave handles the calendar carrying for you (month lengths, year boundaries):

%dw 2.0
output application/json
var placedAt = |2026-06-16T14:30:00-04:00|
---
{
  shipBy:    placedAt + |P2D|,   // two days later
  returnBy:  placedAt + |P30D|,  // thirty-day return window
  lastMonth: placedAt - |P1M|    // same day, previous month
}

For durations you don’t want to hard-code, dw::core::Periods builds them programmatically, and between measures the gap between two instants:

%dw 2.0
import days, between from dw::core::Periods
output application/json
var placedAt  = |2026-06-16T09:00:00Z|
var shippedAt = |2026-06-18T15:30:00Z|
---
{
  window:     days(30),                 // |P30D| — a Period value
  turnaround: between(placedAt, shippedAt)  // the span between the two, as a Period
}

now() (from the always-imported core) gives you the current DateTime, and coercing it is how you truncate — now() as Date throws away the time, now() as LocalDateTime throws away the zone.

Shifting zones with >>

The >> operator answers “what does this same instant read as in another zone?” It does not change the instant — it re-expresses it. Point it at a TimeZone:

%dw 2.0
output application/json
var placedAt = |2026-06-16T14:30:00-04:00|  // 2:30pm on the US East Coast
---
{
  original: placedAt,               // 14:30 -04:00
  utc:      placedAt >> |+00:00|,   // 18:30 +00:00 — same moment
  la:       placedAt >> |-07:00|    // 11:30 -07:00 — same moment
}

All three of those are the identical point on the timeline; only the wall-clock reading and offset differ. This is the right tool for the “was it placed yesterday?” question — shift the order’s DateTime into the customer’s zone first, then take .day. Compare raw offsets and you’ll be off by hours at exactly the boundary that matters.

Worked: normalizing an EU order feed

Here’s the shape of a real job — a European feed sends day-first datetimes as strings, and you need clean ISO output plus a computed ship-by date:

[
  { "id": "A-1001", "placed": "14/06/2026 09:15" },
  { "id": "A-1002", "placed": "15/06/2026 22:40" }
]

Parse once into a local variable with do, then reuse it — parsing the same string twice is a smell:

%dw 2.0
output application/json
---
payload map (order) -> do {
  var placed = order.placed as LocalDateTime {format: "dd/MM/yyyy HH:mm"}
  ---
  {
    id:       order.id,
    placedAt: placed as String {format: "yyyy-MM-dd'T'HH:mm"},
    shipBy:  (placed + |P2D|) as String {format: "yyyy-MM-dd"}
  }
}
[
  { "id": "A-1001", "placedAt": "2026-06-14T09:15", "shipBy": "2026-06-16" },
  { "id": "A-1002", "placedAt": "2026-06-15T22:40", "shipBy": "2026-06-17" }
]

That’s the whole pattern: coerce the messy string into a real temporal type as early as you can, do all arithmetic and comparison on the typed value, and coerce back to a string only at the very edge, in whatever shape the next system wants.

Final thoughts

Dates punish guessing. The teams that never have a timezone incident aren’t luckier — they’re disciplined about one thing: turn strings into typed temporal values at the boundary, keep the zone attached, and only render back to text on the way out. DataWeave gives you the types to do exactly that. The mm/MM trap will still get you once. Let it get you once, learn it, and move on.

Next: The Standard Library You Keep Rewriting By Hand

Comments