---
title: "Dates and Time Ranges · RUAL Documentation"
description: "Dates are stored in seconds, three _meta fields are in milliseconds, and a filter in the wrong unit returns nothing without erroring."
canonical: https://docs.rual.nl/blueprints/time-ranges
language: en
---

[Cluster](https://docs.rual.nl/cluster)

[Blocks](https://docs.rual.nl/block-types)

[Interfaces](https://docs.rual.nl/interfaces)

[Blueprints](https://docs.rual.nl/blueprints)

[Tutorials](https://docs.rual.nl/tutorials)

[Home automation](https://docs.rual.nl/home-automation)

[Examples](https://docs.rual.nl/examples)

[Reference](https://docs.rual.nl/reference)

[Architecture](https://docs.rual.nl/architecture)

[Troubleshooting](https://docs.rual.nl/troubleshooting)

Other

# Dates and Time Ranges

Every date the platform stores is a Unix timestamp in seconds. Which fields are the exception, how to filter a period safely, and how to normalise a value that arrives in milliseconds.

Dates are the one value type where the platform is strict about units and nothing warns you when you get them wrong. A timestamp in the wrong unit is still a valid number, so a filter built on one is still a valid query: it simply matches nothing. This page covers the units, the blocks that convert between them, and the checks worth running the moment a date filter comes back empty.

## Everything is seconds

Every date the platform stores is a Unix timestamp in **seconds**. That covers your own blueprintfields of type `date` and almost all of the timestamps storage maintains for you in `_meta`. A date pin reads a bare number as seconds: there is no per-pin unit setting, and nothing looks at the size of the number to decide what you meant. The only blocks that do are the two conversion blocks further down, which exist for exactly that purpose.

Two numbers do not mean what they look like:

- `0` is **no date**, not midnight on 1 January 1970. `_meta.removed` is `0` on every document that has not been removed, and the conversion blocks below report `valid` as false for it.

- `-1` on `_meta.expiry` is **never**. The [`date_never`](https://docs.rual.nl/block-types/date/date_never) block is what produces it.

| Field | Unit | Notes |
| --- | --- | --- |
| Any blueprintfield of type `date` | Seconds | Including arrays of dates. |
| `_meta.created` | Seconds | Written once, when the document is created. |
| `_meta.updated` | Seconds | Rewritten on every update. |
| `_meta.expiry` | Seconds | `-1` means never. See [document expiry](https://docs.rual.nl/blueprints/storage#document-expiry). |
| `_meta.removed` | Seconds | `0` means the document is active. |
| `_meta.cms` | **Milliseconds** | The same instant as `_meta.created`. |
| `_meta.ums` | **Milliseconds** | The same instant as `_meta.updated`. |
| `_meta.rms` | **Milliseconds** | The same instant as `_meta.removed`. |

> **The three millisecond fields are the only exception** `_meta.ums`, `_meta.cms` and `_meta.rms` hold milliseconds. Everything else is seconds. Filtering one of the three with a seconds value asks for a moment in January 1970 and matches nothing, and filtering a seconds field with a milliseconds value asks for a date tens of thousands of years from now. Neither produces an error. Convert the value first with [`date_convert_to_unix_milliseconds`](https://docs.rual.nl/block-types/date/date_convert_to_unix_milliseconds).

## Filtering a period

Reach for a range rather than an equality. A timestamp is a point on a second-by-second axis, and two events almost never land on the same second, so an exact match on a stored timestamp is nearly always a filter that returns nothing by accident. "Created on 3 March" is a range from the start of that day to the end of it, not an equality.

| Block | Use it for |
| --- | --- |
| [`query_bool_range_date_between`](https://docs.rual.nl/block-types/query/query_bool_range_date_between) | A closed period: everything from one date up to another. |
| [`query_bool_range_date_gte`](https://docs.rual.nl/block-types/query/query_bool_range_date_gte) and its `gt`, `lte`, `lt` siblings | An open-ended period: everything since a date, or everything before one. |
| [`query_bool_range_date_field`](https://docs.rual.nl/block-types/query/query_bool_range_date_field) | A range where the field is chosen at runtime. |
| [`query_bool_range_field`](https://docs.rual.nl/block-types/query/query_bool_range_field) | A numeric range, for a timestamp you keep in a plain number field rather than a date field. |

To build the bounds, start from [`date_currentdate`](https://docs.rual.nl/block-types/date/date_currentdate) and shift it with the `plus` and `set to` blocks. A day boundary is [`date_startofday`](https://docs.rual.nl/block-types/date/date_startofday) and [`date_endofday`](https://docs.rual.nl/block-types/date/date_endofday). Both cut the day in whatever timezone the date reaching them carries, so starting from `date now` gives you the entity timezone, which is what you want for anything a person will read. Feed one a bare epoch number instead and the day is cut in UTC. "The last 24 hours" is `date now` plus `-1` day fed into a `>=` range on `_meta.created`, inside a [`query_bool_filter`](https://docs.rual.nl/block-types/query/query_bool_filter).

If you genuinely need one exact second, express it as a closed range with the same value on both ends rather than an equality. That is what the platform does internally for the same reason, and it survives a change of storage engine unchanged.

## Why a numeric date filter can come back empty

Storage runs on Elasticsearch, and Elasticsearch 9 changed how it reads a bare JSON number in a date filter. A number in a `range` or `term` against a date field is now read as epoch **milliseconds**, whatever format the field is mapped with. Elasticsearch 7 read that same number through the field's own format. Every field in the seconds column of the table above is mapped as an epoch-second date, so an unadorned numeric-seconds bound against one of them asks for a moment in January 1970 and matches nothing.

Nothing reports it, because an empty answer is a valid answer. Measured on Elasticsearch 9.5.1, against a field holding seconds:

| Filter as sent | Documents matched |
| --- | --- |
| `"gte": 1787418000, "lte": 1787504400` | **0** |
| `"gte": "1787418000", "lte": "1787504400"` | 32,492 |
| `"gte": 1787418000, "lte": 1787504400, "format": "epoch_second"` | 32,492 |
| `"term": { "timestamp": 1787504400 }` | **0** |
| `"term": { "timestamp": "1787504400" }` | 115 |

### The platform compensates for this

This is a sharp edge the platform handles rather than one you have to work around, and it is worth knowing which half is already behind you. Core's own readers, such as the cluster usage statistics, the purger and the expired-document exclusion, state their format explicitly and read seconds correctly on both Elasticsearch generations.

The filters *you* build are repaired at the store, on the way to the cluster, for every field it can prove holds seconds: the `_meta` seconds fields, and the entity's own blueprintfields of type `date`. Concretely:

- A range with **numeric** bounds over one of those fields gets the epoch-second format pinned onto the clause, so the number is read as seconds again.

- A **numeric equality** on one of those fields is converted into the equivalent closed range, because a term query has no format parameter that could be pinned. A list of values becomes an OR of closed ranges, matching what the same equality matched on Elasticsearch 7. Any values in the list that are not plain non-zero numbers stay behind in an ordinary equality clause alongside it.

That covers the filters you build on the canvas with the `query_bool_range` and `query_bool_term` blocks, the datatable filters, and the basic filters on UI components, along with the filter aggregations underneath them. String bounds are deliberately left alone, because they already parse through the field's own format on both Elasticsearch generations. So is a `0` in an equality, since epoch 0 is the same instant in either unit. A zero used as a *range* bound is treated like any other number, which is what an open-ended "everything up to this date" filter needs.

The practical consequence for you is that a numeric date filter is not something to hand-tune. Write the range you mean, and keep the value in the unit the field uses.

## The store-side repair arrives with core 15.0.0

The core-side half is in place already. The repair of author-built filters described above, and the two conversion blocks further down, land with a core build in the 15.0.0 line and are not in every released build yet. Until the build your cluster runs includes them, those two blocks will not appear in the block search or have a block page here, and a bare numeric range against a date field on an Elasticsearch 9 entity is the empty-result case this page describes. If you hit it, add a [`query_bool_range_date_format`](https://docs.rual.nl/block-types/query/query_bool_range_date_format) fragment set to `epoch_second` inside your [`query_bool_range_field`](https://docs.rual.nl/block-types/query/query_bool_range_field), which states on the clause exactly what the store will shortly state for you. A range that already carries a format is left alone by the repair, so it keeps working afterwards. Everything else here applies today.

> **The three millisecond fields are excluded from the repair, on purpose** `_meta.ums`, `_meta.cms` and `_meta.rms` are mapped in milliseconds, and their callers correctly send milliseconds. Pinning a seconds format onto those would read the values as years far beyond 9999. This is the one place where the unit is yours to get right, which is what the convert blocks below are for.

## Values that arrive in milliseconds

The platform can repair the shape of a query. It cannot repair a value that reached your flow in the wrong unit. JavaScript's `Date.now()` returns milliseconds, and so do most external APIs. Feed one straight into a date pin and it is read as seconds, which puts it tens of thousands of years in the future, with no error anywhere along the way.

Two blocks normalise a value to a stated unit, detecting what it already is rather than trusting you to know:

| Block | Produces | Use it when |
| --- | --- | --- |
| [`date_convert_to_unix_seconds`](https://docs.rual.nl/block-types/date/date_convert_to_unix_seconds) | Unix seconds | Storing into a `date` field, or filtering on `_meta.created`, `updated`, `expiry` or `removed`. |
| [`date_convert_to_unix_milliseconds`](https://docs.rual.nl/block-types/date/date_convert_to_unix_milliseconds) | Unix milliseconds | Filtering on `_meta.ums`, `_meta.cms` or `_meta.rms`, or handing a timestamp to an external API that expects milliseconds. |

They detect the unit by magnitude rather than assuming it. A number whose size reaches `100000000000`, which is 12 digits and reads as March 1973 in milliseconds, is treated as milliseconds; anything under that is treated as seconds. The test is on the size of the number regardless of sign, so a negative timestamp for a date before 1970 is classified the same way. Numeric strings are read as numbers. Anything that is not a number, such as a date object or a formatted date string, is parsed the way every other date pin parses it, in the entity timezone.

A fractional input does not survive: the seconds block floors to a whole second, and the milliseconds block rounds to a whole millisecond. No date field on the platform stores anything finer.

Both blocks expose a `valid` condition out-pin, and it is worth branching on. The `number` out-pin is `0` when the input was empty, zero or unparseable, and `0` is itself a legitimate "no date" value, so the number alone cannot tell you whether the conversion worked.

Do not wire the arithmetic by hand on the canvas. A multiply by 1000 is correct only for as long as every value reaching it is really in seconds, and the day one caller sends the other unit, or the external API changes, the multiplication is silently wrong in a way that produces a plausible number and an empty result set. The blocks check the magnitude on every run instead.

## Millisecond values from before March 1973 need converting by hand

Magnitude detection has one blind spot. A millisecond timestamp from before March 1973 is smaller than the threshold, so it cannot be told apart from a seconds timestamp and is read as seconds. Any timestamp between 1973 and the year 5138 is unambiguous, in either unit, and a seconds value of any age is always safe. It is only historical dates carried as millisecond epochs that need the arithmetic done yourself.

## When a date filter returns nothing

An empty result set, with no error and no log line, on a filter that looks correct, is what a unit mismatch looks like. The query is valid, the field exists and the value is a real number, so there is nothing for the platform to complain about. Check the unit before you start taking the query apart:

- **Which field is it?** `_meta.ums`, `_meta.cms` and `_meta.rms` need milliseconds. Everything else needs seconds.

- **Where did the value come from?** If it came from a browser, a webhook or an external API, assume milliseconds until you have checked, and run it through [`date_convert_to_unix_seconds`](https://docs.rual.nl/block-types/date/date_convert_to_unix_seconds).

- **Count the digits.** A seconds timestamp for any date near today is 10 digits. A 13-digit number is milliseconds.

- **Is it an equality where you meant a range?** An exact match on a stored timestamp matches only the documents that share that exact second.

- **Is the value `0`?** That is "no date" rather than 1970, so an empty or failed conversion upstream can arrive looking like a valid bound.

A quick way to confirm the unit is to read one document back and compare: put the value you are filtering with next to the `_meta.created` of a document you know should match. If one is three digits longer than the other, that is the whole bug.

## Next steps

- [Storages](https://docs.rual.nl/blueprints/storage): The document model, the full _meta field list, and how to query documents.

- [Common Pitfalls](https://docs.rual.nl/blueprints/common-pitfalls): Other places where the platform does what you asked rather than what you meant.

- [Date blocks](https://docs.rual.nl/block-types/date): Every date block: shifting, comparing, formatting and converting.

## Frequently asked

**What unit are dates stored in on RUAL?**

Unix seconds. That covers every blueprintfield of type date and the _meta timestamps created, updated, expiry and removed. The exceptions are _meta.ums, _meta.cms and _meta.rms, which hold the same instants in milliseconds. Every date pin on every block reads a bare number as seconds, so nothing infers the unit from the value.

**Why does my date filter in RUAL return no results?**

Almost always because the value is in the wrong unit. A timestamp in milliseconds is still a valid number, so the query is valid and simply matches nothing, with no error and no log line. Check whether the field is one of the three millisecond _meta fields, and whether the value came from outside the platform, since JavaScript and most external APIs deliver milliseconds. A seconds timestamp for a date near today is 10 digits and a milliseconds one is 13.

**How do I convert a millisecond timestamp to seconds in RUAL?**

Use the date_convert_to_unix_seconds block, which arrives with core 15.0.0. It detects the unit by magnitude rather than assuming it: numbers reaching 100000000000 are read as milliseconds and divided, smaller ones pass through as seconds. Branch on its valid out-pin rather than trusting the number, because 0 means no date. Use date_convert_to_unix_milliseconds for the reverse, which is what filtering _meta.ums, _meta.cms or _meta.rms needs.

**Should I filter timestamps with a range or an exact match in RUAL?**

A range. A timestamp is a point on a second-by-second axis, so an exact match only finds documents that share that exact second. A day is a range from the start of the day to the end of it, built with date_startofday and date_endofday so the entity timezone is respected.

Was this page helpful? [Tell us what to improve](https://docs.rual.nl/support) · RUAL Docs is an integral component of the [RUAL ecosystem](https://rual.nl)
