Use this skill when the user asks to build ArangoDB-based applications in any project that uses ArangoDB and `arangojs`.
Use this skill when building, reviewing, debugging, optimizing, or explaining ArangoDB-based applications in any project that uses ArangoDB and arangojs.
This skill is for development-time work, including:
arangojs usage from Node.js / TypeScriptThis skill is written to be practical for everyday backend development.
This skill covers the parts of ArangoDB that are most relevant during application development:
arangojs driver patternsBefore writing queries, identify:
Do not jump straight to query syntax before understanding the data model.
Build the simplest correct query first:
FORFILTERRETURNSORTLIMITBind parameters are for values, not for AQL syntax.
In arangojs:
id, q, offset, limit, status, and category should be bind parametersFILTER, SORT, or LIMIT must be composed using nested aql fragmentsIf sorting or filtering by field name is dynamic:
Return only what the caller needs. Avoid returning full documents unless explicitly necessary.
Choose indexes, schema, and query shape according to:
ArangoDB is a multi-model database centered on JSON documents. The same engine supports:
Core primitives:
_from and _toUse this order during implementation:
EXPLAIN / profiling when performance matters.Each document has built-in attributes such as:
_key_id_revFor edge documents:
_from_toUse your own top-level fields for:
Example document:
{
"_key": "brand-001",
"name": "Acme",
"discount": 10,
"ownerId": "users/42",
"status": "active",
"isDeleted": false,
"createdAt": "2026-03-31T12:00:00Z",
"updatedAt": "2026-03-31T12:00:00Z"
}
Some applications need a logical boundary such as:
If the application has that boundary:
Generic pattern:
FILTER doc.scopeId == @scopeId
Common pattern:
FILTER doc.isDeleted == false
If most production queries exclude deleted records:
Prefer _key values that are:
Avoid relying on accidental formatting differences.
Use for entities like:
Use for:
Example edge:
{
"_from": "users/42",
"_to": "brands/brand-001",
"role": "owner",
"createdAt": "2026-03-31T12:00:00Z"
}
For event-like data:
FOR doc IN collection
FILTER ...
SORT ...
LIMIT ...
RETURN ...
INSERT ... INTO collection
RETURN NEW
UPDATE ... IN collection
RETURN NEW
REPLACE ... IN collection
RETURN NEW
REMOVE ... IN collection
RETURN OLD
UPSERT ... INSERT ... UPDATE ... IN collection
RETURN NEW
FOR brand IN product_brands
FILTER brand.isDeleted == false
RETURN {
id: brand._key,
name: brand.name,
discount: brand.discount
}
FOR brand IN product_brands
FILTER brand.isDeleted == false
FILTER LIKE(LOWER(brand.name), CONCAT('%', LOWER(@q), '%'))
SORT brand.createdAt DESC
RETURN {
id: brand._key,
name: brand.name,
discount: brand.discount
}
LET total = FIRST(
FOR brand IN product_brands
FILTER brand.isDeleted == false
FILTER @q == null OR LIKE(LOWER(brand.name), CONCAT('%', LOWER(@q), '%'))
COLLECT WITH COUNT INTO count
RETURN count
)
LET rows = (
FOR brand IN product_brands
FILTER brand.isDeleted == false
FILTER @q == null OR LIKE(LOWER(brand.name), CONCAT('%', LOWER(@q), '%'))
SORT brand.createdAt DESC
LIMIT @offset, @limit
RETURN {
id: brand._key,
name: brand.name,
discount: brand.discount
}
)
RETURN { total, rows }
INSERT {
name: @name,
discount: @discount,
isDeleted: false,
createdAt: DATE_ISO8601(DATE_NOW()),
updatedAt: DATE_ISO8601(DATE_NOW())
} INTO product_brands
RETURN NEW
FOR brand IN product_brands
FILTER brand._key == @id
FILTER brand.isDeleted == false
UPDATE brand WITH {
name: @name,
discount: @discount,
updatedAt: DATE_ISO8601(DATE_NOW())
} IN product_brands
RETURN NEW
FOR brand IN product_brands
FILTER brand._key == @id
FILTER brand.isDeleted == false
UPDATE brand WITH {
isDeleted: true,
updatedAt: DATE_ISO8601(DATE_NOW())
} IN product_brands
RETURN NEW
UPSERT { name: @name }
INSERT {
name: @name,
discount: @discount,
isDeleted: false,
createdAt: DATE_ISO8601(DATE_NOW()),
updatedAt: DATE_ISO8601(DATE_NOW())
}
UPDATE {
discount: @discount,
isDeleted: false,
updatedAt: DATE_ISO8601(DATE_NOW())
}
IN product_brands
RETURN NEW
FOR doc IN product_brands
FILTER doc._key == @id
RETURN doc
FOR brand IN product_brands
LET owner = FIRST(
FOR user IN users
FILTER user._key == PARSE_IDENTIFIER(brand.ownerId).key
RETURN {
id: user._key,
firstName: user.firstName,
lastName: user.lastName,
email: user.email
}
)
RETURN {
id: brand._key,
name: brand.name,
owner
}
FOR brand IN product_brands
COLLECT discount = brand.discount WITH COUNT INTO count
SORT discount ASC
RETURN { discount, count }
RETURN LENGTH(
FOR brand IN product_brands
FILTER LOWER(brand.name) == LOWER(@name)
FILTER brand.isDeleted == false
LIMIT 1
RETURN 1
) > 0
FOR key IN @keys
LET doc = DOCUMENT(product_brands, key)
FILTER doc != null
RETURN doc
FOR doc IN @docs
INSERT doc INTO product_brands
RETURN NEW._key
FOR patch IN @patches
LET doc = DOCUMENT(product_brands, patch._key)
FILTER doc != null
UPDATE doc WITH patch IN product_brands
RETURN NEW
COLLECT WITH COUNT INTO for countsRETURN shapes minimalFILTER conditions as early as possiblePRUNE in traversals when you want to stop exploring a path earlyFILTER, SORT, or LIMIT strings into aqlFILTER when you really need PRUNE in traversalsIndexes speed up reads but cost:
Only add indexes for real access paths.
Every collection has:
_key_from and _to on edge collectionsUse for:
Example:
await collection.ensureIndex({
type: "persistent",
name: "idx_brand_name",
fields: ["name"],
});
Use when business rules require uniqueness.
Example:
await collection.ensureIndex({
type: "persistent",
name: "uniq_brand_name",
fields: ["name"],
unique: true,
});
Use only when documents should expire automatically.
Example:
await collection.ensureIndex({
type: "ttl",
name: "ttl_sessions_expiresAt",
fields: ["expiresAt"],
expireAfter: 0,
});
Use TTL for:
Avoid TTL when:
Use for:
Example:
await collection.ensureIndex({
type: "geo",
name: "geo_locations",
fields: ["location"],
geoJson: true,
});
Use for:
These are central for search-style workloads.
Use for embedding similarity search. This feature is version-sensitive and may require a server startup option depending on server version and deployment.
Use vector indexes for:
Use on edge collections when traversals repeatedly filter by edge attributes in addition to _from or _to.
Example use case:
typetype == "friend"Pattern:
_from or _to plus the filtered edge attributesBefore creating an index, ask:
FILTER uses it?LIKE is enoughUse ordinary AQL LIKE for:
Example:
FOR doc IN products
FILTER LIKE(LOWER(doc.title), CONCAT('%', LOWER(@q), '%'))
RETURN doc.title
Use search indexing when you need:
FOR doc IN productSearch
SEARCH ANALYZER(doc.title IN TOKENS(@q, "text_en"), "text_en")
SORT BM25(doc) DESC
LIMIT 20
RETURN {
id: doc._id,
title: doc.title,
score: BM25(doc)
}
FOR doc IN myView
SEARCH ANALYZER(doc.title == @title, "identity")
RETURN doc.title
Use analyzer-backed search or n-gram / fuzzy patterns when typo tolerance matters. Do not fake fuzzy search with brute-force full scans in production.
Use edge collections and traversals when:
FOR v, e, p IN 1..3 OUTBOUND @startVertex brandRelations
RETURN {
vertex: v,
edge: e,
depth: LENGTH(p.edges)
}
FOR v, e, p IN 1..3 OUTBOUND @startVertex brandRelations
FILTER e.type == "owner"
RETURN v
Use PRUNE to stop exploring deeper when a path should not continue.
FOR v, e, p IN 1..5 OUTBOUND @startVertex brandRelations
PRUNE e.status == "inactive"
RETURN {
vertexId: v._id,
depth: LENGTH(p.edges)
}
FILTER removes resultsPRUNE stops further exploration of that pathIf the goal is traversal efficiency, PRUNE is often the right tool.
FOR v, e IN OUTBOUND SHORTEST_PATH @start TO @target brandRelations
RETURN { vertex: v, edge: e }
FOR path IN OUTBOUND K_SHORTEST_PATHS @start TO @target brandRelations
LIMIT 5
RETURN path
Use weighted traversals or path queries only when edge weights truly matter. Keep edge weight semantics explicit and consistent.
Transactions matter when:
At development time, think in two broad categories:
Streaming transactions are often the practical choice for application code using a driver because they support begin / step / commit / abort behavior across multiple requests.
arangojs streaming transaction exampleimport { Database, aql } from "arangojs";
const db = new Database();
const brands = db.collection("product_brands");
const audits = db.collection("audit_logs");
const trx = await db.beginTransaction({
write: [brands, audits],
});
try {
const cursor = await trx.step(() =>
db.query(aql`
FOR brand IN ${brands}
FILTER brand._key == ${"brand-001"}
UPDATE brand WITH {
discount: ${15},
updatedAt: DATE_ISO8601(DATE_NOW())
} IN ${brands}
RETURN NEW
`),
);
const updated = await cursor.all();
await trx.step(() =>
audits.save({
entityType: "brand",
entityKey: "brand-001",
action: "discount-updated",
createdAt: new Date().toISOString(),
}),
);
await trx.commit();
} catch (err) {
await trx.abort();
throw err;
}
Use a transaction when:
Avoid oversized transactions:
For most application work:
Schema validation is useful for:
Typical use cases:
{
"rule": {
"type": "object",
"properties": {
"name": { "type": "string", "minLength": 1 },
"discount": { "type": "number" },
"isDeleted": { "type": "boolean" }
},
"required": ["name", "isDeleted"],
"additionalProperties": true
},
"level": "strict",
"message": "Invalid brand document"
}
Common levels include:
nonenewmoderatestrictChoose strict when all new and modified documents must comply.
Choose moderate when legacy invalid data exists and you need a gradual cleanup strategy.
Computed values let a collection derive top-level attributes automatically on insert, update, or both.
Use computed values for:
Examples:
nameLowerfullNamesortableTitleupdatedAtDateOnlyarangojs essentialsimport { Database } from "arangojs";
const db = new Database({
url: process.env.ARANGODB_URL,
});
db.useDatabase(process.env.ARANGODB_DATABASE!);
db.useBasicAuth(process.env.ARANGODB_USERNAME!, process.env.ARANGODB_PASSWORD!);
const brands = db.collection("product_brands");
const users = db.collection("users");
const brandRelations = db.edgeCollection("brand_relations");
aqlAlways use aql for dynamic queries.
import { aql } from "arangojs";
const cursor = await db.query(aql`
FOR brand IN ${brands}
RETURN brand
`);
joinimport { aql } from "arangojs";
import { join } from "arangojs/aql";
const filters = [aql`FILTER brand.isDeleted == false`];
if (q?.trim()) {
filters.push(
aql`FILTER LIKE(LOWER(brand.name), CONCAT('%', LOWER(${q.trim()}), '%'))`,
);
}
const query = aql`
FOR brand IN ${brands}
${join(filters, "\n")}
RETURN brand
`;
Bad:
const filter = `FILTER brand.name == "${q}"`;
const query = aql`
FOR brand IN ${brands}
${filter}
RETURN brand
`;
This fails because the string becomes a bind parameter, not actual query syntax.
literal(...)Use literal(...) only for rare trusted edge cases.
Prefer nested aql fragments almost always.
const sortFieldMap: Record<string, any> = {
createdAt: aql`brand.createdAt`,
name: aql`brand.name`,
discount: aql`brand.discount`,
};
const sortField = sort
? (sortFieldMap[sort] ?? aql`brand.createdAt`)
: aql`brand.createdAt`;
const sortDirection = order === "ASC" ? aql`ASC` : aql`DESC`;
const offset = (currentPage - 1) * limit;
const limitClause = fetchAll ? aql`` : aql`LIMIT ${offset}, ${limit}`;
find() patternasync find(dto, options) {
const {
q,
limit = 20,
currentPage = 1,
sort,
order = "DESC",
fetchAll = false,
} = dto;
const filters = [
aql`FILTER brand.isDeleted == false`,
];
if (q?.trim()) {
filters.push(
aql`FILTER LIKE(LOWER(brand.name), CONCAT('%', LOWER(${q.trim()}), '%'))`
);
}
const sortFieldMap = {
createdAt: aql`brand.createdAt`,
name: aql`brand.name`,
discount: aql`brand.discount`,
};
const sortField =
sort ? sortFieldMap[sort] ?? aql`brand.createdAt` : aql`brand.createdAt`;
const sortDirection = order === "ASC" ? aql`ASC` : aql`DESC`;
const offset = (currentPage - 1) * limit;
const limitClause = fetchAll ? aql`` : aql`LIMIT ${offset}, ${limit}`;
const query = aql`
LET total = FIRST(
FOR brand IN ${this.brandsCollection}
${join(filters, "\n")}
COLLECT WITH COUNT INTO count
RETURN count
)
LET rows = (
FOR brand IN ${this.brandsCollection}
${join(filters, "\n")}
SORT ${sortField} ${sortDirection}
${limitClause}
RETURN {
id: brand._key,
name: brand.name,
discount: brand.discount
}
)
RETURN { total, rows }
`;
const [result] = await this.runAql(query);
return {
data: result?.rows ?? [],
meta: {
total: result?.total ?? 0,
limit,
currentPage,
},
};
}
When a query is slow, ask:
LIKE when search indexing is needed?PRUNE stop useless paths early?EXPLAINUse EXPLAIN when you need to know:
Profile important queries to inspect:
Plan caching can reduce repeated planning overhead for identical query shapes. Do not confuse:
They solve different problems.
Use result caching carefully and only when:
FOR doc IN coll
FILTER doc.isDeleted == false
FILTER doc.status == @status
SORT doc.createdAt DESC
LIMIT 20
RETURN {
id: doc._key,
status: doc.status,
createdAt: doc.createdAt
}
FOR doc IN coll
RETURN doc
Then filter and sort in application code.
For large update-style AQL jobs, consider:
FOR doc IN coll
FILTER doc.isDeleted == false
SORT doc.createdAt DESC
LIMIT @offset, @limit
RETURN KEEP(doc, "_key", "name", "createdAt")
Prefer database uniqueness when possible. If app-level prechecks are still needed:
RETURN LENGTH(
FOR doc IN coll
FILTER LOWER(doc.name) == LOWER(@name)
LIMIT 1
RETURN 1
) > 0
FOR brand IN product_brands
LET owner = FIRST(
FOR user IN users
FILTER user._id == brand.ownerId
RETURN KEEP(user, "_key", "firstName", "lastName", "email")
)
RETURN MERGE(
KEEP(brand, "_key", "name", "discount"),
{ owner }
)
RETURN LENGTH(
FOR edge IN brand_relations
FILTER edge._from == @from
FILTER edge._to == @to
LIMIT 1
RETURN 1
) > 0
LET updated = FIRST(
FOR doc IN brands
FILTER doc._key == @id
UPDATE doc WITH { discount: @discount } IN brands
RETURN NEW
)
INSERT {
entityType: "brand",
entityKey: updated._key,
action: "updated",
createdAt: DATE_ISO8601(DATE_NOW())
} INTO audit_logs
RETURN updated
Wrap in a transaction if both writes must succeed or fail together.
Use this rule:
LIKE for tiny/simple admin toolsconst sort = `SORT doc.${field}`;
Do not do this.
If the application uses workspace, organization, project, or account scoping, every relevant query must apply that boundary consistently.
Adding many overlapping indexes slows writes and increases memory use.
Always bound depth unless the domain truly requires otherwise.
Project the fields you need.
Use _key, _id, or persistent indexes instead.
Sometimes one subquery is simpler and faster than a graph traversal.
unexpected bind parameter near '@value2'Cause:
aql templateBad:
const filter = "FILTER x.a == 1";
aql`FOR x IN ${coll} ${filter} RETURN x`;
Fix:
const filter = aql`FILTER x.a == ${1}`;
aql`FOR x IN ${coll} ${filter} RETURN x`;
Check:
_key vs _id mismatchLIMIT offset too largeCheck:
Check:
LIKE on large data without search indexingPRUNECheck:
Check:
Check:
Before finalizing a solution, verify:
aql, not strings?_key vs _id handled correctly?RETURN?PRUNE help?When the user asks about ArangoDB:
aql`FILTER doc.status == ${status}`;
aql`FILTER LIKE(LOWER(doc.name), CONCAT('%', LOWER(${q}), '%'))`;
aql`LIMIT ${offset}, ${limit}`;
const direction = order === "ASC" ? aql`ASC` : aql`DESC`;
COLLECT WITH COUNT INTO count
RETURN count
RETURN KEEP(doc, "_key", "name", "createdAt")
Use these rules as defaults:
The agent should prefer the official references below when checking details:
DatabaseDocumentCollectionaqlliteralIn ArangoDB development: model around access patterns, bind values, compose AQL safely, index only real workloads, and profile the hot paths.