Series: Time Processing Overview | Tutorial 1 | Tutorial 2: Durations | Tutorial 3: Intervals | Tutorial 4: Creative Patterns
What you will learn
By the end of this tutorial you will be able to:
- Represent dates and times as typed literals in WOQL
- Compare dates with
less,greater,lte, andgte - Filter values into half-open ranges with
in_range - Explain the difference between "as of" and "period" queries
- Know when to use
triple_slicefor O(log n) lookups on stored data
Prerequisites
- A running TerminusDB instance (Docker is easiest:
docker run --rm -p 6363:6363 terminusdb/terminusdb-server) - Basic familiarity with WOQL concepts (WOQL Tutorial)
Every playground below runs against your local TerminusDB. Click Settings on any playground to change the server URL or credentials. The time predicates work in anonymous mode — no database needed — because they are pure computations.
Part 1: Typed Literals — How Dates Are Represented
Every value in WOQL is a typed literal: a value string paired with a type. The literal() function creates one. Run this to see what a date looks like:
Bind a date literal to a variable. Notice the @type and @value in the result.
What happened: The variable v:today is now bound to a value with type xsd:date and value "2025-03-31". Every date in TerminusDB carries its type — this is how the system knows to compare it as a calendar date, not as a string.
The date and time types you will use most
| Type | Format | Example | When to use |
|---|---|---|---|
xsd:date | YYYY-MM-DD | "2025-03-31" | Calendar dates: periods, filings, deadlines |
xsd:dateTime | YYYY-MM-DDThh:mm:ssZ | "2025-03-31T16:00:00Z" | Timestamps: trades, audit trails |
xsd:gYear | YYYY | "2025" | Fiscal years |
xsd:gYearMonth | YYYY-MM | "2025-03" | Reporting months |
Try changing the type and value in the playground above. What happens if you use xsd:gYearMonth with "2025-03"?
Part 2: Comparing Dates
WOQL provides four comparison predicates. They work on every ordered type — dates, numbers, strings.
| Predicate | Meaning | Succeeds when |
|---|---|---|
less(A, B) | A < B | A is strictly before B |
greater(A, B) | A > B | A is strictly after B |
lte(A, B) | A ≤ B | A is before or equal to B |
gte(A, B) | A ≥ B | A is after or equal to B |
Step 2: Strict comparison
Check if the first date is before the second. Result shows true/false.
What happened: The result shows true (1 row returned). The less predicate succeeds because January 1 is before March 31.
Try changing the dates so the first is after the second — you'll see false (0 rows) because the comparison fails.
Step 3: Boundary equality
Does >= succeed when both dates are the same?
What happened: The result shows true. The gte (greater than or equal) predicate succeeds when values are equal — that's the "or equal" part. If you used greater instead, you'd see false because the dates are not strictly greater.
Key insight: This distinction matters for "as of" queries (covered below). Use gte when you want to include the boundary date, greater when you want to exclude it.
Step 4: Compare different types
Comparisons work on gYearMonth too. January is before June.
Part 3: Half-Open Range Filtering with in_range
in_range(Value, Start, End) succeeds when Start ≤ Value < End. The start is inclusive, the end is exclusive. This [start, end) convention is called a half-open range.
Step 5: Value inside the range
Checks if February 15 falls within [Jan 1, Apr 1).
Step 6: Start is inclusive
The start date itself IS in the range.
Step 7: End is exclusive
April 1 is NOT in [Jan 1, Apr 1). Zero results.
Why half-open? Because adjacent ranges partition cleanly:
- Q1 =
[Jan 1, Apr 1)— contains Mar 31, not Apr 1 - Q2 =
[Apr 1, Jul 1)— contains Apr 1, not Jul 1 - No date falls in both. No date falls in neither. No gap between them.
Step 8: Integer ranges work too
Is 7 in [5, 10)? Works with any ordered type.
Part 4: "As Of" vs "Period" Queries {#as-of}
This is the most important distinction in financial time processing. Mixing up these two patterns is the #1 source of off-by-one-day bugs.
Pattern 1: "As of" — cumulative state at a point in time
"What is the balance as of March 31?" means: all transactions up to and including that date.
The query pattern uses lte — the as-of date is inclusive:
// "As of March 31" — lte is inclusive
and(
triple(v.doc, "transaction_date", v.date),
lte(v.date, literal("2025-03-31", "xsd:date"))
)Pattern 2: "During period" — transactions in a range
"Show me Q1 transactions" means: transactions in the half-open interval [Jan 1, Apr 1).
The query pattern uses in_range — the end date is exclusive:
// "During Q1" — in_range has exclusive end
and(
triple(v.doc, "transaction_date", v.date),
in_range(v.date,
literal("2025-01-01", "xsd:date"),
literal("2025-04-01", "xsd:date"))
)The difference that matters
| Query | Predicate | Mar 31 included? | Apr 1 included? |
|---|---|---|---|
| "As of Mar 31" | lte(date, Mar31) | Yes | No |
| "Q1 period" | in_range(date, Jan1, Apr1) | Yes | No |
Both include March 31. But "as of" also includes everything before Q1. The period query only includes Q1. And critically, the "as of" query uses the inclusive end (Mar 31), while the period query uses the exclusive end (Apr 1).
Part 5: Working with Stored Data
The playgrounds above used anonymous mode (pure computation). For queries on stored data, you need a database. The patterns below work with any schema that has date properties.
triple_slice — O(log n) range queries
When data is stored in TerminusDB, triple_slice pushes range constraints into the storage engine. It replaces triple + in_range with a single indexed lookup:
// Instead of this (scans all values):
and(
triple(v.doc, "transaction_date", v.date),
in_range(v.date, start, end)
)
// Use this (indexed lookup):
triple_slice(v.doc, "transaction_date", v.date, start, end)| Situation | Use | Why |
|---|---|---|
| Filtering stored property values | triple_slice | O(log n), uses storage index |
| Filtering computed values | in_range | Works on any bound value |
| Checking a single value | in_range | Simpler for one-off checks |
Deep dive: See the triple_slice guide for advanced usage.
Part 6: Timezone Handling
Dates are timezone-agnostic
Calendar dates ignore timezone offsets for comparison and arithmetic:
Calendar dates compare by date value only.
DateTimes normalize to UTC
DateTime values are converted to UTC before comparison. 23:00+02:00 is 21:00Z, which is before 22:00Z:
23:00+02:00 = 21:00 UTC, which is before 22:00 UTC.
For financial systems: store timestamps in UTC. Timezone conversion is a display concern, not a data concern.
Self-Check
Before moving to the next tutorial, make sure you can answer these:
- What is the difference between
lessandlte? - Why does
in_range(10, 5, 10)fail? - When would you use
lteinstead ofin_rangefor a date query? - Why does
triple_sliceexist whenin_rangealready works?
What You Learned
| Concept | Key Point |
|---|---|
| Typed literals | literal("2025-03-31", "xsd:date") — always pair value with type |
| Comparators | less/lt (strict), greater/gt (strict), lte, gte |
in_range | Half-open [start, end) — start inclusive, end exclusive |
| "As of" queries | Use lte — the as-of date is included |
| Period queries | Use in_range — the end date is excluded |
triple_slice | O(log n) indexed range lookup for stored data |
| Timezones | Dates: agnostic. DateTimes: UTC-normalized |