AEL reference
For the complete documentation index see: llms.txt
All documentation pages available in markdown.
AEL is a text-based DSL that compiles to Aerospike server expressions. It is
used for filter expressions (.where()) and as the source of read/write
expressions in both Java and Python:
.selectFrom() / .select_from(), .insertFrom() / .insert_from(),
.updateFrom() / .update_from(), and .upsertFrom() / .upsert_from().
Using AEL in the SDK
Filter expressions with .where()
session.query(users) .where("$.age > 21 and $.status == 'active'") .execute();📖 API reference:
Session.query(DataSet)|ChainableQueryBuilder.where(...)|ChainableQueryBuilder.execute()
stream = await session.query(users).where("$.age > 21 and $.status == 'active'").execute()# stream.close() is synchronous (not await)📖 API reference:
Session.query()|QueryBuilder.where()|RecordStream.close()|QueryBuilder.execute()
Single-key and batch operations:
session.update(users.id("u1")) .bin("lastSeen").setTo(System.currentTimeMillis()) .where("$.status == 'active'") .execute();📖 API reference:
DataSet.id(...)|Session.update(DataSet)|ChainableOperationBuilder.bin(...)|ChainableQueryBuilder.bin(...)|ChainableQueryBuilder.where(...)|ChainableQueryBuilder.execute()
import time
await ( session.update(users.id("u1")) .bin("lastSeen").set_to(int(time.time() * 1000)) .where("$.status == 'active'") .execute())📖 API reference:
DataSet.id()|Session.update()|QueryBuilder.where()|QueryBuilder.bin()|WriteSegmentBuilder.set_to()|WriteSegmentBuilder.bin()|WriteSegmentBuilder.execute()
Parameterized expressions with PreparedAel
Avoid string concatenation by using placeholders.
Java uses $1, $2, … and Python uses ?0, ?1, … in the AEL string.
PreparedAel prepared = new PreparedAel("$.age > $1 and $.name == $2");
session.query(users) .where(prepared, 21, "Tim") .execute();📖 API reference:
Session.query(DataSet)|ChainableQueryBuilder.where(...)|ChainableQueryBuilder.execute()
from aerospike_sdk import parse_ael
# Parameterized expression (?0, ?1, ... in the string; values are bound positionally)expr = parse_ael("$.age > ?0 and $.name == ?1", 21, "Tim")stream = await session.query(users).where(expr).execute()📖 API reference:
Session.query()|QueryBuilder.where()|QueryBuilder.execute()
Java placeholders are 1-based in the string and 0-based in the params array; Python placeholders are 0-based in both the string and the bound values.
Quick syntax reference
Record paths
Every path starts with $:
$.binName -- scalar bin$.profile.name -- map key access$.scores.[0] -- list index access$.data.users.[2].name -- deeply nestedBin type inference
The first context element determines the bin type:
| Path | Inferred type |
|---|---|
$.x.name | Map (first context is identifier) |
$.x.[0] | List (first context is [) |
$.x > 5 | Scalar (no context) |
Comparison operators
$.age > 21$.name == 'Tim'$.price >= 100.0$.status != 'inactive'Both sides can be bins (requires explicit type):
$.binA.get(type: INT) > $.binB.get(type: INT)The in operator
$.name in ["Bob", "Mary", "Richard"]"gold" in $.allowedStatusesLogical operators
$.age > 21 and $.status == 'active'$.role == 'admin' or $.role == 'super'not($.age > 65)exclusive($.a > 10, $.b > 5)and binds tighter than or. Use parentheses to clarify:
$.a > 1 and ($.b > 2 or $.c > 3)Arithmetic
($.price * $.qty) > 1000($.apples + 5) > 10abs($.score - 50) < 10max($.a, $.b, $.c) > 100$.value % 2 == 0$.base ** 2 > 100Both operands must be the same type. Use asInt() / asFloat() to convert
between numeric types, or to cast a numerically-valued bin before comparison:
$.intBin + $.floatBin.asInt()$.count.asFloat() > 3.143.14.asInt() == 3asInt() / asFloat() converts between the two numeric types (INT ↔
FLOAT). It does not parse string-typed bins. A bin stored as the string
"42" cannot be compared numerically with .asInt(). Store the value as an
integer-typed bin instead.
Integer division and %: When both operands of / are INT bins,
Aerospike performs integer division only when the result is exact.
A division that produces a remainder (such as 460 / 3) is evaluated as a
FLOAT. Because % (modulo) requires integer operands on both sides, using
% after a non-exact division raises ParameterError on the server. To
stay safe with %, either:
- design fixture data so all divisions are exact (such as
10 / 2, not10 / 3), or - cast back with
.asInt()before applying%:($.a / $.b).asInt() % 10.
Record metadata
$.ttl() < 3600 -- expires in < 1 hour$.recordSize() > 1024 -- large records$.sinceUpdate() < 7200000 -- updated in last 2 hours$.isTombstone() -- deleted records$.setName() == 'critical'$.digestModulo(3) == 0 -- partition sampling| Function | Returns | Description |
|---|---|---|
$.ttl() | INT | Time-to-live in seconds |
$.voidTime() | INT | Absolute expiry (-1 = never) |
$.lastUpdate() | INT | Last update (ns since epoch) |
$.sinceUpdate() | INT | Ms since last update |
$.setName() | STRING | Record’s set name |
$.keyExists() | BOOL | Whether user key is stored |
$.isTombstone() | BOOL | Whether record is deleted |
$.recordSize() | INT | Total size in bytes |
$.deviceSize() | INT | Storage size on device |
$.memorySize() | INT | Size in memory |
$.digestModulo(n) | INT | Digest modulo n |
CDT path patterns
Map access
$.profile.name -- string key (dot notation)$.profile.["name"] -- string key (bracket notation, equivalent)$.profile.'special-key' -- quoted string key (dot notation, for keys with special chars)$.profile.["special-key"] -- bracket notation alternative$.m.1 -- integer key$.m.{1} -- map by index$.m.{=bb} -- map by value$.m.{#1} -- map by rankReserved keywords require bracket notation. Some identifiers are reserved
in the AEL grammar (type, count, exists, get, in, and, or, not, etc.).
Using them as map key names with dot notation causes an AelParseException.
Always use bracket notation for such keys:
-- Fails if 'type' is a reserved word:$.metadata.type == 'fixture'-- Correct: bracket notation bypasses keyword parsing$.metadata.["type"] == 'fixture'List access
$.scores.[0] -- by index$.scores.[-1] -- last element$.scores.[=42] -- by value$.scores.[#0] -- by rank (lowest)Plural selectors (leaf only)
$.m.{a-d} -- key range [a, d) ← dash separates KEY range bounds$.m.{a,b,c} -- key list$.m.{0:3} -- index range ← colon separates INDEX range bounds$.m.{=10:20} -- value range$.m.{#-3:} -- top 3 by rank$.l.[1:5] -- list index range$.l.[=1,2,3] -- list value list$.l.[#0:3] -- list rank rangeKey range vs index range: do not confuse the delimiters.
{a-d} uses a dash (-) and selects by key (string or integer keys in
lexicographic/numeric order). {0:3} uses a colon (:) and selects by
position index. Using : with string bounds like {room1:room3} is a
parse error; the correct form is {room1-room3}.
-- String key range from "room1" up to (but not including) "room3":$.rooms.{room1-room3}-- Count of entries in that range:$.rooms.{room1-room3}.count()-- WRONG: colon is for index ranges, not key ranges:$.rooms.{room1:room3} ← parse errorInverted selections (prefix !)
$.m.{!a-d} -- everything except keys a-c$.l.[!0:3] -- everything except indices 0-2$.m.{!=temp,draft} -- everything except these keysPath functions
get() — explicit type and return
$.binName.get(type: INT)$.mapBin.{a,b}.get(return: KEY_VALUE)$.listBin.[0:3].get(return: COUNT)| Parameter | Values |
|---|---|
type | INT, STRING, FLOAT, BOOL, BLOB, HLL, LIST, MAP, GEO |
return | VALUE, COUNT, INDEX, RANK, NONE, EXISTS, KEY, KEY_VALUE, ORDERED_MAP, UNORDERED_MAP |
count() — element count
$.listBin.[].count() -- list size$.mapBin.{}.count() -- map size$.listBin.[=4].count() > 0 -- count of elements equal to 4exists() — existence check
$.binA.exists() and $.binB.exists()$.mapBin.address.exists()Control structures
Conditional: when
when ($.tier == 1 => 'gold', $.tier == 2 => 'silver', default => 'bronze')default clause is mandatory.
Variable binding: let ... then
let (total = $.price * $.qty, discount = ${total} / 10) then (${total} - ${discount} > 400)Variables reference earlier variables with ${name}.
Float literals in let expressions cause silent failures
If any value in a let binding is a float literal (such as 0.1, 1.5) or produces a
FLOAT result, but the bin it is combined with is INT, the expression silently
returns no matches. There is no parse error. This also applies to ${var} * 0.1
where ${var} holds an integer.
Use only integer arithmetic in let expressions unless all operands are explicitly floats.
-- Safe: all INT arithmeticlet (total = $.price * $.qty, discount = ${total} / 10) then (${total} - ${discount} > 400)
-- Silent failure: INT * float literallet (total = $.price * $.qty, tax = ${total} * 0.1) then (${total} + ${tax} > 900)
-- Fixed: keep everything INT (scale by 1000 or use integer percentages)let (total = $.price * $.qty, tax_pct = 10, tax = ${total} / ${tax_pct}) then (${total} + ${tax} > 990)Type consistency in let bindings. All arithmetic within a let
expression must use consistent types. Mixing INT and FLOAT values in the
same arithmetic expression (such as ${total} is INT but $.discount_rate is
FLOAT) causes the expression to silently return no matches. There is no
parse error, but the filter produces zero results.
To avoid this:
-
Store all fixture bins used in
letarithmetic as the same type (allINTor allFLOAT), or -
Use
.asFloat()/.asInt()explicitly to cast before combining.
-- All INT arithmetic:let (total = $.price * $.qty, discount = ${total} / 10) then (${total} - ${discount} > 400)
-- Mixed types (NOTE: INT * FLOAT silently fails if types don't align):let (total = $.price * $.qty, discount = ${total} * $.discount_rate) then (${total} - ${discount} > 400)
-- Fixed with explicit cast:let (total = $.price * $.qty, discount = ${total}.asFloat() * $.discount_rate) then (${total}.asFloat() - ${discount} > 400.0)Read expressions with selectFrom
selectFrom evaluates an AEL expression server-side and returns the result as a virtual bin. The bin doesn’t need to exist — the expression can reference any bins on the record.
Record rec = session.query(users.id("u1")) .bin("total").selectFrom("$.price * $.quantity") .bin("ageIn10Years").selectFrom("$.age + 10") .execute() .getFirstRecord();
long total = rec.getLong("total");long futureAge = rec.getLong("ageIn10Years");📖 API reference:
DataSet.id(...)|Session.query(Key)|ChainableQueryBuilder.bin(...)|ChainableQueryBuilder.execute()|RecordStream.getFirstRecord()|Record.getLong(...)
stream = await ( session.query(users.id("u1")) .bin("total").select_from("$.price * $.quantity") .bin("ageIn10Years").select_from("$.age + 10") .execute())first = await stream.first_or_raise()total = first.record.bins["total"]stream.close()📖 API reference:
DataSet.id()|Session.query()|QueryBuilder.bin()|WriteSegmentBuilder.bin()|RecordStream.first_or_raise()|RecordStream.close()|QueryBuilder.execute()
The result bin name is whatever you pass to .bin(...) — it doesn’t need to match any existing bin. Use ignoreEvalFailure() to skip records where the expression can’t evaluate:
session.query(users) .bin("ratio").selectFrom("$.a / $.b", opt -> opt.ignoreEvalFailure()) .execute();📖 API reference:
Session.query(DataSet)|ChainableQueryBuilder.bin(...)|ChainableQueryBuilder.execute()
Write expressions with insertFrom, updateFrom, upsertFrom
These evaluate an AEL expression server-side and write the result into a bin, with different existence semantics:
| Method | Behavior |
|---|---|
upsertFrom(ael) | Creates or overwrites the bin |
insertFrom(ael) | Creates only — fails with BIN_EXISTS_ERROR if bin exists |
updateFrom(ael) | Updates only — fails with BIN_NOT_FOUND if bin doesn’t exist |
session.upsert(users.id("u1")) .bin("total").upsertFrom("$.price * $.quantity") .bin("discount").insertFrom("$.coupon * 0.1") .execute();📖 API reference:
DataSet.id(...)|Session.upsert(DataSet)|Session.upsert(Key)|ChainableOperationBuilder.bin(...)|ChainableQueryBuilder.bin(...)|ChainableQueryBuilder.execute()
await ( session.upsert(users.id("u1")) .bin("total").upsert_from("$.price * $.quantity") .bin("discount").insert_from("$.coupon * 0.1") .execute())📖 API reference:
DataSet.id()|Session.upsert()|QueryBuilder.bin()|WriteSegmentBuilder.bin()|WriteSegmentBuilder.execute()
Options for write expressions:
session.upsert(users.id("u1")) .bin("discount").upsertFrom("$.coupon", opt -> opt .deleteIfNull() // delete the bin if expression returns null .ignoreEvalFailure()) // don't fail if expression can't evaluate .bin("bonus").insertFrom("$.base * 1.5", opt -> opt .ignoreOpFailure()) // don't fail if bin already exists .execute();📖 API reference:
DataSet.id(...)|Session.upsert(DataSet)|Session.upsert(Key)|ChainableOperationBuilder.bin(...)|ChainableQueryBuilder.bin(...)|ChainableQueryBuilder.execute()
Where clause integration patterns
On dataset queries
session.query(users) .where("$.age > 21") .execute();📖 API reference:
Session.query(DataSet)|ChainableQueryBuilder.where(...)|ChainableQueryBuilder.execute()
stream = await session.query(users).where("$.age > 21").execute()📖 API reference:
Session.query()|QueryBuilder.where()|QueryBuilder.execute()
On single-key / batch operations
session.update(users.ids("u1", "u2")) .bin("bonus").add(100) .where("$.department == 'engineering'") .execute();📖 API reference:
DataSet.ids(...)|Session.update(DataSet)|ChainableOperationBuilder.bin(...)|ChainableQueryBuilder.bin(...)|BinBuilder.add(...)|ChainableQueryBuilder.where(...)|ChainableQueryBuilder.execute()
await ( session.update(users.ids("u1", "u2")) .bin("bonus").add(100) .where("$.department == 'engineering'") .execute())📖 API reference:
DataSet.ids()|Session.update()|QueryBuilder.where()|QueryBuilder.bin()|WriteSegmentBuilder.bin()|WriteSegmentBuilder.execute()
On mixed batch chains (per-operation and default)
session .update(users.id("u1")).bin("bonus").add(100) .where("$.department == 'engineering'") .update(users.id("u2")).bin("bonus").add(50) .defaultWhere("$.active == true") .execute();📖 API reference:
DataSet.id(...)|ChainableQueryBuilder.bin(...)|BinBuilder.add(...)|ChainableQueryBuilder.where(...)|ChainableQueryBuilder.execute()
await ( session.update(users.id("u1")).bin("bonus").add(100).where("$.department == 'engineering'") .update(users.id("u2")).bin("bonus").add(50) .default_where("$.active == true") .execute())📖 API reference:
DataSet.id()|Session.update()|QueryBuilder.where()|QueryBuilder.bin()|WriteSegmentBuilder.bin()|WriteSegmentBuilder.execute()
With PreparedAel for reuse
PreparedAel activeInDept = new PreparedAel("$.active == true and $.department == $1");
session.query(users) .where(activeInDept, "engineering") .execute();
session.query(users) .where(activeInDept, "marketing") .execute();📖 API reference:
Session.query(DataSet)|ChainableQueryBuilder.where(...)|ChainableQueryBuilder.execute()
from aerospike_sdk import parse_ael
# Same template string; bind parameters per query via parse_ael(...)expr_eng = parse_ael("$.active == true and $.department == ?0", "engineering")stream_eng = await session.query(users).where(expr_eng).execute()stream_eng.close() # sync, not await
expr_mkt = parse_ael("$.active == true and $.department == ?0", "marketing")stream_mkt = await session.query(users).where(expr_mkt).execute()stream_mkt.close()📖 API reference:
Session.query()|QueryBuilder.where()|RecordStream.close()|QueryBuilder.execute()