This guide covers the practical side of working with data in WOQL: what types exist, how values flow through queries, and how to reshape results for real-world use. It assumes you have completed the Interactive Tutorial or are comfortable with triple, and, or, and eq.
How WOQL Produces Results
Every WOQL query returns a table of solutions. Each row is one valid assignment of all variables; each column is one variable. Understanding how those rows are generated is the key to writing effective queries.
Solutions and Backtracking
WOQL is built on a Datalog/Prolog engine. When the engine encounters a predicate that can match multiple values, it produces one solution row per match. If a later constraint fails for a particular match, the engine backtracks — it discards that partial solution and tries the next match.
This means:
andnarrows the solution set. Every constraint must hold simultaneously, so incompatible rows are eliminated.orexpands the solution set. Each branch that succeeds contributes its own rows.optpreserves rows. If the inner pattern fails, the row survives with unbound variables instead of being eliminated.
// Two triples share v.person — natural join via backtracking.
// The engine iterates all name triples, then for each match
// backtracks into age triples looking for the same v.person.
let v = Vars("person", "name", "age")
and(
triple(v.person, "name", v.name),
triple(v.person, "age", v.age)
)The result is not a loop — it is the set of all simultaneously valid bindings. Each row where both triples hold for the same v.person is one solution.
Solutions Are Not Lists
A common source of confusion: the result table looks like a list, but inside the query it behaves differently. Each row is produced independently by backtracking. You cannot index into "row 3" or iterate the result set within the same query.
To work with results as a collection inside a query, you need group_by to collect rows into an actual list variable. More on that below.
Variable Types and Values
Typed Literals
All values stored in TerminusDB carry an XSD type. When you read a property with triple, the object position holds a typed literal — not a bare JavaScript string or number.
| Schema type | XSD type | Example value |
|---|---|---|
xsd:string | xsd:string | "Alice" |
xsd:integer | xsd:integer | 42 |
xsd:decimal | xsd:decimal | 3.14 |
xsd:boolean | xsd:boolean | true |
xsd:dateTime | xsd:dateTime | "2025-01-15T10:30:00Z" |
xsd:date | xsd:date | "2025-01-15" |
When matching a specific value in a triple, wrap it with literal():
// Match the exact typed string "New York"
triple("v:person", "city", literal("New York", "xsd:string"))Without literal(), WOQL may interpret a bare string as a variable name or IRI rather than a data value.
Checking and Matching Types with type_of
The type_of predicate works in multiple directions thanks to unification:
// Direction 1: Discover the type of a value
type_of("v:value", "v:its_type")
// Direction 2: Filter to only strings
type_of("v:value", "xsd:string")
// Direction 3: Check a specific value's type
type_of(literal(42, "xsd:integer"), "xsd:integer")See the Datatypes Cookbook for detailed examples.
Typecasting with typecast
Convert between types with typecast:
// Cast a string to an integer
typecast("v:string_val", "xsd:integer", "v:int_val")
// Cast an integer to a string
typecast(literal(42, "xsd:integer"), "xsd:string", "v:as_string")
// Cast a dateTime string to xsd:dateTime
typecast(
literal("2025-01-15T10:30:00Z", "xsd:string"),
"xsd:dateTime",
"v:timestamp"
)typecast is essential when comparing values from different sources. CSV columns arrive as strings, so casting them to numeric types before comparison prevents type mismatches:
and(
get(as("Age", "v:age_str")).post("people.csv", { type: "csv" }),
typecast("v:age_str", "xsd:integer", "v:age"),
greater("v:age", 30)
)DocumentTemplate and the Doc Wrapper
What Doc Does
Doc() (or new Doc()) converts a plain JavaScript object into a sys:Dictionary structure that WOQL can traverse. This is how you bring raw JSON data into a query without storing it in the database first.
eq("v:data", new Doc({
name: "Alice",
scores: [85, 92, 78]
}))The resulting v:data is not a plain object — it is a dictionary with a specific internal structure that the dot operator can navigate.
Navigating with dot
The dot operator accesses fields within a dictionary:
and(
eq("v:data", new Doc({ name: "Alice", city: "London" })),
dot("v:data", "name", "v:name"), // v:name = "Alice"
dot("v:data", "city", "v:city") // v:city = "London"
)For nested structures, chain dot calls:
and(
eq("v:data", new Doc({
user: { address: { city: "Paris" } }
})),
dot("v:data", "user", "v:user"),
dot("v:user", "address", "v:addr"),
dot("v:addr", "city", "v:city") // v:city = "Paris"
)Arrays in Dicts
When a dict field contains an array, dot binds the array as a list. Use member to iterate:
and(
eq("v:data", new Doc({
tags: ["urgent", "billing", "customer"]
})),
dot("v:data", "tags", "v:tag_list"),
member("v:tag", "v:tag_list") // One row per tag
)See Extract Table from JSON for a complete walkthrough.
Reading Documents into Dicts
read_document Returns a Dict
read_document assembles all triples for a document into a single dictionary variable:
let v = Vars("id", "doc")
and(
isa(v.id, "Person"),
read_document(v.id, v.doc)
)The v.doc variable now holds the full document as a dict. You can extract fields with dot:
let v = Vars("id", "doc", "name", "age")
and(
isa(v.id, "Person"),
read_document(v.id, v.doc),
dot(v.doc, "name", v.name),
dot(v.doc, "age", v.age)
)When to Use triple vs. read_document
| Use case | Approach |
|---|---|
| Filter by specific properties | triple — only reads what you need |
| Join across documents | triple — shared variables create the join |
| Get the complete document | read_document — one call, all fields |
| Pass document data downstream | read_document + dot — structured access |
A common pattern is to filter with triple, then read the full document for matching results:
let v = Vars("id", "doc", "name")
and(
triple(v.id, "age", "v:age"),
greater("v:age", 30),
read_document(v.id, v.doc),
dot(v.doc, "name", v.name)
)Lists and the member Predicate
Lists in WOQL
WOQL lists are ordered collections that appear in two contexts:
- Schema-defined lists/arrays — stored as triples with
sys:indexandsys:valuepatterns - Query-constructed lists — created by
group_byor literal notation
Iterating with member
member generates one solution row per element in a list:
and(
eq("v:colors", ["red", "green", "blue"]),
member("v:color", "v:colors")
)
// Result: 3 rows — one for each colorThis is the standard way to "loop" in WOQL. There are no for-loops — instead, member drives backtracking over the list elements.
Combining member with Other Predicates
Use member to test containment, filter lists, or cross-reference:
// Check if a value is in a list
and(
eq("v:allowed", ["admin", "editor", "viewer"]),
member("v:role", "v:allowed"),
eq("v:role", literal("editor", "xsd:string"))
)
// Cross-reference: for each person, check if their city is in a target list
and(
triple("v:person", "name", "v:name"),
triple("v:person", "city", "v:city"),
member("v:city", [
literal("London", "xsd:string"),
literal("Paris", "xsd:string")
])
)Collecting Solutions with group_by
How group_by Works
group_by is the bridge between the streaming world of backtracking and the collection world of lists. It runs an inner query, groups the results by specified variables, and collects the grouped values into a list.
group_by(
[grouping_variables], // Variables to group by (the "key")
[collected_variables], // Variables to collect into lists (the "value")
"v:result_list", // The output list variable
inner_query // The query that produces rows
)Counting Per Group
and(
group_by(
["city"], // Group by city
["person"], // Collect person IDs
"v:people_in_city",
and(
isa("v:person", "Person"),
triple("v:person", "city", "v:city")
)
),
length("v:people_in_city", "v:count")
)Result: one row per city, with v:count holding the number of people.
Collecting All Values into a Single List
Use an empty grouping key [] to collect everything into one list:
group_by(
[], // No grouping — one big group
["name"], // Collect all names
"v:all_names",
and(
isa("v:person", "Person"),
triple("v:person", "name", "v:name")
)
)
// v:all_names is a single list of all person namesProcessing Grouped Results
After group_by, use member to iterate over the collected list, or length to count, or set operations to compare:
and(
// Collect names per city
group_by(
["city"],
["name"],
"v:names",
and(
triple("v:person", "city", "v:city"),
triple("v:person", "name", "v:name")
)
),
// Iterate over each name in the group
member("v:one_name", "v:names")
)Converting to and from CSV
Reading CSV Files
Use get and as to read CSV columns into variables. Each row produces one solution via backtracking:
get(
as("Name", "v:name")
.as("Age", "v:age")
.as("City", "v:city")
).post("people.csv", { type: "csv" })CSV values arrive as xsd:string. Cast them when needed:
and(
get(as("Name", "v:name").as("Age", "v:age_str"))
.post("people.csv", { type: "csv" }),
typecast("v:age_str", "xsd:integer", "v:age"),
greater("v:age", 25)
)Collecting CSV into a List
To process CSV data as a whole (for set operations, comparisons, etc.), wrap it in group_by:
group_by(
[],
["name"],
"v:all_csv_names",
get(as("Name", "v:name")).post("people.csv", { type: "csv" })
)Importing CSV into Documents
Combine CSV reading with insert_document:
and(
get(
as("Name", "v:name")
.as("Age", "v:age_str")
.as("City", "v:city")
).post("people.csv", { type: "csv" }),
typecast("v:age_str", "xsd:integer", "v:age"),
insert_document(
Doc({
"@type": "Person",
"name": "v:name",
"age": "v:age",
"city": "v:city"
}),
"v:id"
)
)Comparing Database with CSV
See the CSV Comparison Guide for detailed patterns using set_difference, set_intersection, and streaming comparisons for large datasets.
Converting to and from JSON
Bringing JSON into a Query
Use Doc() to bring arbitrary JSON into WOQL's dict format, then navigate with dot and member:
let v = Vars("data", "items", "item", "id", "name")
and(
eq(v.data, new Doc({
items: [
{ id: "1", name: "Widget" },
{ id: "2", name: "Gadget" }
]
})),
dot(v.data, "items", v.items),
member(v.item, v.items),
dot(v.item, "id", v.id),
dot(v.item, "name", v.name)
)
// Result: 2 rows — one per itemSee Extract Table from JSON for filtering, nesting, and comparison patterns.
Getting Documents as JSON
read_document returns the document in TerminusDB's JSON format. In client code, the bindings response contains the full document as a JSON object:
// JavaScript client
const result = await client.query(
WOQL.and(
WOQL.isa("v:id", "Person"),
WOQL.read_document("v:id", "v:doc")
)
)
// Each binding has the complete document as a JS object
for (const row of result.bindings) {
console.log(row.doc)
// { "@type": "Person", "name": "Alice", "age": 28, ... }
}# Python client
result = client.query(
wq().woql_and(
wq().isa("v:id", "@schema:Person"),
wq().read_document("v:id", "v:doc")
)
)
for row in result["bindings"]:
print(row["doc"])
# {'@type': 'Person', 'name': 'Alice', 'age': 28, ...}Reshaping JSON Output
Combine read_document with dot and select to extract exactly the fields you need:
let v = Vars("id", "doc", "name", "city")
select(v.name, v.city,
and(
isa(v.id, "Person"),
read_document(v.id, v.doc),
dot(v.doc, "name", v.name),
dot(v.doc, "city", v.city)
)
)
// Result: only name and city columns, no document ID or full docPutting It All Together
Here is a realistic example that combines several techniques: reading CSV data, querying the database, grouping results, and using member and dot to reshape output.
Scenario: Reconcile Employee Records
You have a CSV of current employee IDs and want to find which ones are in the database, what department they belong to, and group them by department.
let v = Vars(
"csv_id", "csv_list", "csv_set",
"emp", "doc", "name", "dept",
"dept_group", "dept_count"
)
and(
// Step 1: Load CSV into a set
group_by([], ["csv_id"], v.csv_list,
get(as("EmployeeId", "v:csv_id"))
.post("current-employees.csv", { type: "csv" })
),
list_to_set(v.csv_list, v.csv_set),
// Step 2: For each employee in CSV that exists in DB,
// read their document and extract fields
member("v:lookup_id", v.csv_set),
isa(v.emp, "Employee"),
triple(v.emp, "employee_id", "v:lookup_id"),
read_document(v.emp, v.doc),
dot(v.doc, "name", v.name),
dot(v.doc, "department", v.dept),
)To count per department, wrap the above in a group_by:
and(
group_by(
["dept"],
["name"],
"v:names_in_dept",
and(
// ... the query from above ...
)
),
length("v:names_in_dept", "v:dept_count")
)Quick Reference
| Concept | Predicate | What it does |
|---|---|---|
| Bind a value | eq(var, value) | Assigns a value to a variable |
| Typed literal | literal(value, type) | Creates a typed value for matching |
| Check/discover type | type_of(value, type) | Matches or discovers a value's XSD type |
| Convert type | typecast(input, target_type, output) | Casts between XSD types |
| Navigate dict | dot(dict, key, value) | Accesses a field in a dictionary |
| Read full document | read_document(id, doc) | Loads a complete document as a dict |
| Create dict in query | Doc({...}) / new Doc({...}) | Wraps JSON as a sys:Dictionary |
| Iterate a list | member(element, list) | One row per list element |
| Group results | group_by(keys, vals, list, query) | Collects backtracked rows into lists |
| Count a list | length(list, count) | Returns the number of elements |
| Read CSV | get(as(...)).post(file, {type:"csv"}) | One row per CSV row |
| Set from list | list_to_set(list, set) | Sorted, deduplicated collection |
| Set operations | set_difference, set_intersection | Compare two sets efficiently |
Further Reading
| Topic | Page |
|---|---|
| Interactive tutorial (start here) | Learn WOQL |
| Type matching examples | Datatypes Cookbook |
| Extract tables from JSON | JSON Table Extraction |
| CSV comparison patterns | CSV Comparison |
| Array and set queries | Arrays & Sets |
| Set operations reference | Set Operations |
| Subdocument handling | Subdocuments |
| Variables and unification | Unification |
| Complete reference | WOQL Class Reference |