evitaDB - Fast e-commerce database
logo
page-background

Data types

The article gives an introduction to data types in EvitaDB query language, including basic and complex types, and provides code examples to demonstrate their usage.

This document lists all data types supported by evitaDB that can be used in attributes or associated data for storing client relevant information.

There are two categories of data types:

  1. simple data types that can be used both for attributes and associated data
  2. complex data types that can be used only for associated data

Simple data types

evitaDB data types are limited to following list:

String

The string type is internally encoded with the character set UTF-8. evitaDB query language and other I/O methods of evitaDB implicitly use this encoding.

Dates and times

The local variants of the date time — such as LocalDateTime — are first-class attribute data types. An attribute declared as LocalDateTime stores and returns exactly the wall-clock value you wrote, and the entity schema records LocalDateTime as its data type — whether you declared the attribute up front or let the schema evolve from the first value written.
A local date time carries no offset, so it does not identify a moment in time on its own — two clients in different time zones reading the same value read the same wall clock, not the same instant. That is exactly what you want for wall-clock data such as opening hours or a recurring schedule. If your data is time zone specific, work directly with the OffsetDateTime on the client side and be explicit about the offset from the first day.
An OffsetDateTime is compared, sorted and indexed by the moment it denotes. Two values denoting the same moment at different offsets — 2024-01-01T11:00+02:00 and 2024-01-01T10:00+01:00 — sort as ties, whether you order by the attribute itself or by a sortable attribute compound containing it. The tie is broken like any other tie in the ordering: by the next element of the compound, or by the primary key in the ordering direction. The value you read back keeps the offset you wrote.
Why do we internally use OffsetDateTime for time information?
Offset/time zone handling varies from database to database. We wanted to avoid setting the timezone in session or database configuration properties, as this mechanism is error-prone and impractical. Saving/loading date times with timezone information would be the best option, but we run into problems with parsing in certain environments, and only the date with offset information seems to be widely supported. The offset information is good enough for our case - it identifies a globally valid time that is known at the time the data value is stored.

Millisecond precision

evitaDB stores and matches temporal values at millisecond precision. An OffsetDateTime, LocalDateTime or LocalTime is truncated to whole milliseconds the moment it enters the database — both when it is written as an attribute value and when it is used as an argument of a query constraint. Digits below the millisecond are discarded and cannot be read back.
The truncation happens on both paths on purpose. Cutting only the stored value would leave a nano-precise query probe hunting a value that no longer exists, and the filter would silently match nothing. Because both halves are cut to the same precision, a value written as 2026-05-20T12:19:26.123456789Z is stored as 2026-05-20T12:19:26.123Z and is still found by a query written with the original, nano-precise value. Two values that differ only below the millisecond become indistinguishable — they compare equal, sort as ties and share one index entry.
DateTimeRange follows the same rule. It compares, sorts and matches its boundaries at millisecond precision, so two ranges whose boundaries differ only below the millisecond are the same range — they compare equal, sort as ties and share one index entry. A range keeps the sub-millisecond digits of the boundary values you gave it, but nothing in the database ever looks at them. Two ranges whose boundaries differ by a millisecond or more are different ranges, even when the boundaries fall within the same second.
LocalDate is the only date/time type left out — it carries no sub-day component, so there is nothing to truncate.
evitaDB holds every temporal value as a whole number of milliseconds since the epoch in a 64-bit number. That window spans roughly ±292 million years around 1970, so every realistic calendar date fits comfortably. A value outside it is clamped to its nearest edge — in practice only the JDK's own extreme sentinels such as OffsetDateTime.MAX, which therefore work as "since forever" / "until forever" markers.
The window spans from -292275055-05-16T16:47:06Z to +292278994-08-17T07:12:54.999Z. An OffsetDateTime or LocalDateTime outside it is clamped to its nearest edge, both when it is written as an attribute value and when it is used as an argument of a query constraint. This concerns the JDK's own extreme sentinels — LocalDateTime.MIN / LocalDateTime.MAX and their OffsetDateTime counterparts: OffsetDateTime.MAX is stored as the latest supported instant, keeping its -18:00 offset (+292278994-08-16T13:12:54.999-18:00), it is read back as that value, and attributeEquals("validUntil", OffsetDateTime.MAX) finds it, because the query argument is clamped to the very same value. A LocalDateTime is clamped as the wall clock at UTC. A DateTimeRange boundary beyond the window is clamped to it as well, which makes it behave exactly like an open-ended boundary.
The gRPC API narrows the window further: it carries temporal values as a protobuf Timestamp, which spans 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. A value outside that range leaves the server as its first or last second. A value reaching the server at exactly 0001-01-01T00:00:00Z, or anywhere within the last second of the range, is taken for the MIN / MAX sentinel of its type (for a LocalDate, the first or last day of the range is). Every other moment of year 1 or year 9999 is stored exactly as it was sent. The Java client therefore round-trips OffsetDateTime.MAX as OffsetDateTime.MAX, where the embedded API reads back the clamped value.

DateTimeRange

The DateTimeRange represents a specific implementation of the defining from and to boundaries by the OffsetDateTime data types. The offset date times are written in the ISO format. Both boundaries are compared at millisecond precision.
Range is written as:
  • when both boundaries are specified:
  • when a left boundary (since) is specified:
  • when a right boundary (until) is specified:

NumberRange

The NumberRange represents a specific implementation of the defining from and to boundaries by the Number data types. The supported number types are: Byte, Short, Integer, Long and BigDecimal.

Both boundaries of the number range must be of the same type - you cannot mix for example BigDecimal as lower bound and Byte as upper bound.

Range is written as:
  • when both boundaries are specified:
  • when a left boundary (since) is specified:
  • when a right boundary (until) is specified:

Predecessor

The
is a special data type used to define a single oriented linked list of entities of the same type. It represents a pointer to a previous entity in the list. The head element is a special case and is represented by the constant Predecessor#HEAD. The predecessor attribute can only be used in the attributes of an entity or its reference to another entity. It cannot be used to filter entities, but is very useful for sorting.

Motivation for linked lists in database sorting

The linked list is a very optimal data structure for sorting entities in a database that holds large amounts of data. Inserting a new element into a linked list is a constant time operation and requires only two updates:

  1. inserting a new element into the list, pointing to an existing element as its predecessor
  2. updating the original element pointing to the predecessor to point to the new element.

Moving (updating) an element or removing an existing element from a linked list is also a constant time operation, requiring similar two updates. The disadvantage of the linked list is its poor random access performance (get element at n-th index) and list traversal, which requires a lot of random access to different parts of memory. However, these disadvantages can be mitigated by keeping the linked list in the form of an array or binary tree of properly positioned primary keys.

There are alternatives approaches to this problem, but they all have their downsides. Some of them are summarized in the article "Keeping an ordered collection in PostgreSQL" by Nicolas Goy. We went through similar journey and concluded that the linked list is the least of all evils:
  • It doesn't require mass updates of surrounding entities or occasional "reshuffling".
  • it doesn't force the client logic to be complicated (and it plays well with the UI drag'n'drop repositioning flow)
  • it is very data efficient - it only requires a single (4B) per single item in the list

Maintaining consistency of the linked list

Constructing a linked list could be a tricky process from a consistency point of view - especially in the warm-up phase, when you need to reconstruct the data from an external primary store. To be consistent at all times, you'd need to start with the entity that represents the head of the chain, then insert its successor, and vice versa. This is often not trivial, and if you have two predecessor attributes with different "order" for the same entities, it's absolutely impossible.

That's why we designed our linked list implementation to tolerate partial inconsistencies, and to converge to a consistent state as missing data is inserted. We support these inconsistency scenarios:

  • multiple head elements
  • multiple successor elements for a single predecessor
  • circular dependencies, where a head element points to an element in its tail

The sorting by an inconsistent predecessor attribute sorts the entities by the chains in the following order:

  1. the chains starting with a head element (starting with the chain with most elements, to the chain with least elements)
  2. the chains with elements sharing the same predecessor (starting with the chain with most elements, to the chain with least elements)
  3. the chains with circular dependencies (starting with the chain with most elements, to the chain with least elements)
When the dependencies are fixed, the sort order will converge to the correct one. The will contain only a single chain of correctly ordered elements and will return true when the isConsistent() method is called on it.

The inconsistent state is also allowed in the transactional phase, but we recommend avoiding it and updating all the elements involved (in any order) within a single transaction, which will ensure that the linked list remains consistent for all other transactions.

Complex data types

The complex types are types that don't qualify as simple evitaDB types (or an array of simple evitaDB types). Complex types are stored in a
data structure that is intentionally similar to the JSON data structure so that it can be easily converted to JSON format and can also accept and store any valid JSON document.
Associated data may even contain array of complex objects. Such data will be automatically converted to an array of ComplexDataObject types - i.e. ComplexDataObject[].

The complex type can contain the properties of

Author: Ing. Jan Novotný

Date updated: 23.8.2023

Documentation Source