Common Query Errors (v2)

This page describes common errors on the v2 API and how to resolve them. A theme runs through v2: it fails loud. Conditions that a lenient API might turn into an empty result β€” an unsupported operator, an unknown config profile, a missing test set β€” return a 4xx with a message instead, because an empty result is indistinguishable from "no match" and hides the real problem.

The error response format

Every v2 error β€” from any endpoint, at any status β€” returns the same JSON envelope, so a client can parse errors one way across the whole API:

{
  "kind": "error",
  "data": {
    "code": "not_found",
    "message": "Unknown table 'invoicess'"
  }
}
  • kind is always the literal string "error" β€” the cheapest check for "did this request fail" (independent of the HTTP status).
  • data.code is a stable, machine-readable code (a dotted namespace) β€” branch on this in an SDK. Codes are contractual; the message wording is not.
  • data.message is a human-readable, single-line explanation β€” surface it, but don't parse it.

The HTTP status carries the coarse class (4xx client, 5xx server); the code carries the specific cause. For an SDK, the reliable pattern is: if the body has "kind": "error", read data.code; otherwise treat the body as a normal result.

Code catalogue

Query and read endpoints (_query, _search, _predict, _recommend, _relate, _aggregate, _estimate, _evaluate, _sql, _batch):

codeHTTPCause
query.invalid400The query shape is wrong β€” a bad from, an unrecognised structure.
request.invalid400A value failed validation (bad type, out-of-range argument).
proposition.unsupported400A $-operator isn't applicable to the named field.
query.empty_population400A _predict or _evaluate whose nested from selects no rows β€” there is nothing to learn from. A list endpoint (_query, _search, _recommend, _match, relate $patterns) answers the same case with 200, no hits and a query.empty_population warning. Available since v2.11.4; earlier builds answer request.invalid.
json.malformed400The request body isn't valid JSON.
predict.busy429A predict over very many candidate values did not fit in memory beside other such predicts already running. Nothing failed; retry after Retry-After seconds. See large predicts.
predict.too_large400A predict over so many candidate values that it could never fit in this server's memory for predicts, even alone. See large predicts.
response_too_large413The response would be over the 10 MB response limit for synchronous responses, so it was withheld; the request itself was fine. The operation ran, so a write it made has been applied: don't blindly retry it. Job results are not capped. The message names the limit and the response's actual size. Ask for fewer rows with a smaller limit (or fewer fields with select), or page through the results with offset. On builds without this answer the connection closes with no status at all (behind a proxy, a 502); the response limit says which builds have it.
row_limit_exceeded403A write would pass a row cap: the free tier's, or a licence's own. Not transient, so don't retry it; data.license names the licence state. See quotas. Available since v2.11.8; v2.11.7 and earlier answer 429.
not_found404A referenced table (the from, or a linked table) doesn't exist.
operation.unsupported501A capability isn't implemented for this table/engine (e.g. a rep2-only operator on a legacy rep1 table). Distinct from 500 β€” "not supported here", not "we crashed".
internal500An unexpected server error. Report it.

Data-write endpoints (_delete, _modify, .../batch, .../stream, .../import) and schema endpoints add:

codeHTTPCause
data.bad_request400A write was rejected (e.g. a vector dimension mismatch, or on_conflict on a keyless table).
import.failed Β· table.exists Β· schema.not_collection400Import problems: the import failed, the target table already exists, or it isn't a collection.
write.contention409Concurrent writes to the same table kept moving it until this write gave up. Nothing was written; retry after Retry-After seconds. See write contention.
schema.create_failed Β· schema.column_update_failed Β· schema.refresh_failed Β· schema.plan_failed Β· schema.apply_failed400The schema operation was rejected (the message says why).
not_found404The named table/column doesn't exist.

A _delete / _modify where reads the collection's own fields. A field it does not have β€” a link path such as cust.tier, or a typo β€” is a 400 whose message names the field. Select the rows with a _query on the link path, then change them by their own key.

Available since: v2.11.5. Earlier builds answered such a where with a 200 that matched no row (or, under $not, every row).

Two things this table makes explicit and worth relying on: a typo'd table name is a 404, not a 500 (it reads as "you referenced something missing", not "we crashed"), and an unsupported operation is a 501, not a 500 β€” so a client can tell "this engine can't do that" apart from a genuine bug.

Failures that aren't domain-specific β€” an unauthorised key, a wrong method, a quota refusal β€” carry the envelope too, with a code named after the status: unauthorized (401), forbidden (403), method_not_allowed (405), conflict (409), payload_too_large (413), unsupported_media_type (415), rate_limited (429), unavailable (503). A refusal that carries extra machine-readable facts keeps them beside code and message β€” a free-tier row-quota refusal, for instance, adds limit, table and hint under data.

Available since: v2.7.0 for the 404s from the schema and data endpoints (GET/DELETE /api/v2/schema/{table}, POST /api/v2/data/{table}/batch) and for the status-named codes above. On an older build those specific failures answer with the flat v1 body {"message": …, "status": …} instead of the envelope β€” so a client that must support both should read data.code when kind === "error" and fall back to the top-level message.

Success responses

For contrast, successful responses come in two shapes:

  • Row results (_search, _predict hits, filtered _query) return the v1-compatible body { "offset": …, "total": …, "hits": [ … ] } β€” no kind field (an absent kind means rows).
  • Non-row results (_aggregate, _estimate, _evaluate, _batch) use the same { "kind": …, "data": … } frame as errors β€” e.g. { "kind": "aggregate", "data": { … } }. So the presence of "kind": "error" β€” never just the presence of kind β€” is what marks a failure.

These shapes do not depend on how the table is stored. _estimate, _aggregate and _evaluate run different implementations for a rep2 collection and a legacy rep1 table, but both answer in the {kind, data} frame. Where the legacy path names a payload field the way v1 did, the v2 name is added alongside it: _estimate returns both data.value and data.estimate holding the same number, so data.value is always safe to read.

Available since: v2.7.0 for the engine-independent shape. On an older build, _estimate/_aggregate/_evaluate against a legacy type: "table" return the bare v1 body with no kind β€” which the kind ?? "rows" rule reads as a page of rows with no hits, silently losing the result.

Grouped _aggregate: groupBy and its refusals

_aggregate with groupBy computes its aggregates per group. Each group's values are exactly what the ungrouped _aggregate answers with that group in where, companions included, so one grouped call replaces a _batch of per-group calls:

{ "from": "deals", "groupBy": "stage", "aggregate": ["$f", "amount.$sum"] }

The answer is a page of rows, one hit per group:

{ "offset": 0, "total": 3, "hits": [
  { "stage": "lost", "$f": 3, "amount.$sum": 12.0, "amount.$sum.samples": 2 },
  { "stage": "won",  "$f": 3, "amount.$sum": 30.0, "amount.$sum.samples": 2 },
  { "stage": "open", "$f": 2, "amount.$sum": 0.0,  "amount.$sum.samples": 0 } ] }
  • groupBy takes a field of the table, or an array of them: a String, Int, Long, Decimal, Boolean, Date or Timestamp field. Several keys group by the combinations that occur, not each key separately. A key is written as _query writes the same field.
  • Rows with no value for a key form a group whose key is null.
  • total is the number of groups. limit and offset page the groups; with no limit, every group is returned.
  • Groups come most rows first, ties by key ascending. orderBy takes a groupBy field (ascending), an aggregate key (descending), or {"$asc": key} / {"$desc": key}. A missing value (a null key, or an aggregate over no values) sorts last in either direction.
  • A group whose rows have no value for an aggregated field answers what the ungrouped _aggregate answers over no values: a sum of 0.0, and a mean, minimum and maximum of "NaN" (a JSON string, since NaN is not a JSON number), with .samples 0. Check .samples before reading the value. This is the one place _aggregate and _sql differ: _sql's GROUP BY answers NULL for both the sum and the average over no values, as SQL does.

Refused with 400 and the code aggregate.groupby_unsupported:

  • a Text key: Text is indexed by its tokens, so a row would belong to several groups. Group by a String field, or use _sql.
  • a multi-valued key (an array or set), a vector key, and any other non-scalar type.
  • a key through a link (company.segment): not supported yet. _sql groups by the path: SELECT company.segment, count(*) FROM deals GROUP BY company.segment.

Also refused:

  • more than 10,000 groups: 400 aggregate.too_many_groups. A group-by on a near-unique key is a scan; narrow the where, group by a coarser field, or page the rows with _query.
  • grouping more rows than this server allows for one aggregate (about a quarter of its memory): 400 aggregate.too_large. The rows are counted before any is grouped. Narrow the where, or run per-group aggregates in a _batch.
  • an aggregate named like a groupBy field ({"stage": "$f"} grouped by stage): 400, since a hit holds both.
  • groupBy on a legacy type: "table" (engine v1): 501 operation.unsupported. Use _sql there:
SELECT stage, count(*) AS n FROM deals GROUP BY stage

Available since: v2.11.9. On v2.11.8, groupBy is refused with 400. On v2.11.7 and earlier, a collection ignores groupBy and returns the total over all matching rows with 200, which looks like an answer. A legacy type: "table" refuses it.

503 write.deadline: the write ran past its time and was not committed

A write request (insert, batch, _modify, _delete, a schema change, or an env/release/ref admin write) that runs longer than the server's request timeout is answered with 503 and data.code: "write.deadline". The commit that was in progress is refused: nothing from it is applied, before or after the answer. Retry the request, split it into smaller ones, or submit it as an async job (POST /api/v2/jobs/…), which has no request timeout. The time counts from when the request body has been fully received, so a slow upload does not use up the write's time.

A multi-operation _modify whose operations commit one by one says in the message how many of its operations were committed before the deadline.

Available since: v2.11.8. On v2.11.7 and earlier, a write that outlived the timeout was answered with a bare 503 while it kept running and committing afterwards.

401 or 415 on a POST: the two headers every POST needs

Every POST needs two headers, and each has its own status when it is missing:

  • x-api-key missing, or a key the server does not know: 401, on every route and method (see 401 / 403).
  • content-type: application/json missing or different: 415, whose message starts Content-Type must be application/json and names the header to send. curl -d sends application/x-www-form-urlencoded unless you say otherwise, so this is the usual cause. On /api/v2 the body is the v2 error envelope with the code unsupported_media_type.

If both headers are missing, the key is checked first, so the answer is 401; fix the key, and the next request answers 415 until the content type is set.

Available since: v2.11.2. Up to v2.11.1 both mistakes answered the same plain-text 405 "HTTP method not allowed, supported methods: GET" on a POST, which named neither header (and a GET with no key answered 400).

Fix: send both headers on every POST:

curl -X POST 'https://shared.aito.ai/db/aito-demo/env/v2/api/v2/_query' \
  -H 'x-api-key: yg4rTlXkqDzm4y8gPeY75HCKaNwfbTQ2si64ONTi' \
  -H 'content-type: application/json' \
  -d '{ "from": "products", "limit": 1 }'

Against a local Docker container, the URL is http://127.0.0.1:9005/api/v2/_query and the key is your READ_WRITE_APIKEY (see Docker).

401 / 403 β€” authentication and environments

Almost every auth failure on v2 is one of two things: wrong addressing or a read key used for a write.

There is no env-scoped API key. The same key that reads or writes master reads or writes every environment. An environment is selected purely by URL path β€” never by a dot suffix, a request-body field, a query parameter, or a header:

βœ…  /db/{db}/env/{name}/api/v2/_query      (multi-tenant server)
βœ…  /env/{name}/api/v2/_query              (single-db server)
❌  /db/{db}/env.{name}/api/v2/_query      (dot β€” not an env selector)
❌  body: { "env": "{name}", ... }         (ignored)

The status says which of two things is wrong, on every route and method alike:

  • 401 β€” fix your key. "The required header 'x-api-key' is missing" (no key sent) or "The API key you provided is not recognised" (not a key of this database β€” a typo, a rotated key, or a key of another database).

  • 403 β€” this key can't do that. "not authorized to perform this operation": a real key without the permission β€” almost always a read-only key on a write (PUT/DELETE schema, POST .../_batch). Reads succeed, writes 403 β€” use a read-write key. On the Docker image, the read-only key is APIKEY and the read-write key is READ_WRITE_APIKEY; a write with the read-only key returns 403 with the code forbidden. A 403 with the code row_limit_exceeded is a different thing, a row cap rather than a key problem: see quotas.

    Available since: v2.11.2. Up to v2.11.1 a missing key answered 400 on a GET and 405 "method not allowed, supported methods: GET" on a POST/PUT/DELETE; an unknown key 403 or 405; and a read-only key on a write 401.

  • Every write refused on a self-hosted server. A server checking its own keys (KEY_VALIDATION=LOCAL, as the Docker image does) with no READ_WRITE_APIKEY, or with it set to the empty string, has no write key and refuses every write. Its startup log says READ_WRITE_APIKEY is not set. Set the variable and restart. With neither READ_WRITE_APIKEY nor APIKEY set, the server refuses to start; to run without keys, set DISABLE_API_KEY_AUTH=true and DISABLE_WRITE_API_KEY_AUTH=true. An empty x-api-key header is never a key.

    Available since: v2.12.0. Up to v2.11.9 a server with an unset key generated one at startup and printed it in its log, and that key could write when only APIKEY was set.

  • 403 / 401 on an env path that reads fine on master is almost always an addressing bug β€” a dot instead of /env/, or a gateway rewriting the path. Confirm the env exists with GET /db/{db}/api/v2/_envs, then address it with the slash form above.

501 β€” $patterns / relate on a legacy table

Error: 501 β€” relate $patterns "supported on collections only".

Cause: the v2 _relate $patterns (itemset mining) endpoint requires a rep2 CollectionDb. Tables uploaded through the v1 API are legacy rep1 TableDb, and the v2 endpoint returns 501 for them by design. Two ways forward:

  • Mine now, no rebuild: hit the legacy endpoint POST /api/v1/_relate β€” it mines rep1 tables today.
  • Everything on v2: re-upload the table as a "type": "collection" (see Schema Migration).

Collection written by an older binary format

Error: 409 β€” This collection was written in binary format N, but this Aito build reads format M. …, or Env '<name>' is stored in an older on-disk format and is being migrated in the background … retry shortly. On builds up to v2.8.0 the same situation surfaced as a 500 carrying a raw internal error such as slice [1090930175543, …] out of bounds or ListSeqRepIoType expected 8 entries in OrderDir, got 6 β€” a message about the decoder, on a query that has nothing to do with formats.

Cause: the collection's on-disk state was written by an older Aito build whose binary format has since changed. master migrates at server start; an env branch migrates the first time you use it, so the first request against a not-yet-migrated branch starts the rebuild and is refused while it runs. A state that arrives mid-session β€” typically an env restored from an older backup β€” behaves the same way. See server upgrades and env branches.

Fix: retry. The migration is already running in the background and the same request succeeds once it finishes; a large branch takes longer. Use POST /api/v2/data/{table}/repair (once per collection in that env) when you would rather rebuild immediately than wait, or if a migration reported a failure. Only rep2 collections are affected; rep1 type: "table" tables have their own versioning and migrate at open.

Available since: v2.8.1 for the 409 and the migrate-on-use behaviour. A read-only instance cannot run the migration at all and answers 503 instead, naming that as the reason.

A predict over very many candidate values: 429 predict.busy and 400 predict.too_large

A predict scores every candidate value of its target and keeps some state for each one until the answer is built. Predicting a column with hundreds of thousands of distinct values (a near-unique number such as a price or an amount, an id, a free-text field) therefore needs a lot of memory: roughly 2–3 KB per candidate value. The server admits such a predict by its estimated memory, so one request can never take the server down for everyone.

  • 429 predict.busy, with a Retry-After header: the predict fits on its own, but not beside other large predicts that are running now. It waited for them, in arrival order (up to 30 seconds by default), and they did not finish in time, or too many others were already waiting. Retry the same request. Nothing was changed.
  • 400 predict.too_large: the predict could never fit, even alone. The message names the number of candidate values and the estimate.

To make a large predict small, do one of:

  • For a number (an amount, a price): use _estimate for one value, or store a range bucket beside the number when you write the row ("amountBand": "1000-5000") and predict the bucket. A near-unique number makes a poor predict target anyway: each value occurs about once, so its prior is flat and the exact answer is close to a guess (see near-unique targets).
  • Predict a lower-cardinality field.
  • Narrow the candidates with a where on the predicted field itself, or predict through a link whose target table is smaller.
  • Optimize the table. A table with many unmerged segments costs more per candidate.
  • Use a server with more memory. A self-hosted server sets how much of its heap large predicts may hold together with the system property aito.predict.heapPercent (default 75), the wait with aito.predict.admissionWaitMs (default 30000), how many large predicts may wait at once with aito.predict.maxWaiters (default 4; more are answered 429 at once), and the size below which a predict is not counted with aito.predict.heavyFloorBytes (default 256 MB).

Ordinary predicts, up to well over a hundred thousand candidate values, are not affected. A query that runs several large predicts, and the members of one _batch, share one allowance: they run one after another and never refuse each other.

  • On /api/v1 the same two answers carry the same codes, statuses and Retry-After.
  • Over the PostgreSQL interface, predict.busy is SQLSTATE 53000 (insufficient resources, worth retrying) and predict.too_large is 54000 (program limit exceeded, a retry will not help).
  • In an async job, a large predict that was busy when the job ran leaves the job failed. Its /result answers 429 predict.busy without Retry-After, because fetching the result again returns the same answer: resubmit the job instead.

Available since: v2.11.8. On v2.11.7 and earlier, two very large predicts at the same time can run the server out of memory.

Write contention: 409 write.contention

Error: 409 with the code write.contention and a Retry-After header β€” a positional write re-anchored 64 times without committing: concurrent writes kept moving the table; retry the request.

Cause: a large write that resolves its rows first and commits second β€” a _delete that matches many rows, or a keyed upsert of many rows β€” met a table that other writers had changed in between, re-resolved, and lost the race again on every attempt. It happens when a steady stream of small writes lands on the same table during one large write. Nothing was written: the request either commits whole or not at all.

Fix: retry the same request. It is safe: a write.contention answer means nothing was committed, so the retry cannot apply the write twice.

  • Key on the code, not on 409 alone. Other 409s mean something else: an env being migrated (retry later), a collection in an older binary format (needs a repair, not a retry), a release that already exists. Retry automatically only on write.contention.
  • Back off with jitter. Treat Retry-After as the minimum wait, then grow the wait on each attempt and add a random component (e.g. full jitter: wait a random time between Retry-After and an exponentially growing cap). Under heavy write churn, many clients retrying on a fixed one-second beat keep colliding with each other. Give up after a few attempts.
  • If it keeps happening, pause the small writers to that table for the duration of the large write, or split the large write into smaller batches.

Over the PostgreSQL interface the same condition is SQLSTATE 40001 (serialization failure), which database drivers and ORMs already retry.

Available since: v2.11.5. Earlier builds answer this case with 500.

Unknown field

Error: Unknown field 'fieldName' in table 'tableName'

Cause: the field name doesn't exist in the table schema.

  • Field names are case-sensitive β€” check the spelling.
  • Verify available fields: GET /api/v2/schema/{table}.
  • For linked fields use dot notation: product.name, not productName.
// Wrong β€” field doesn't exist
{ "from": "products", "where": { "productName": "coffee" } }
// Correct
{ "from": "products", "where": { "name": { "$match": "coffee" } } }

Strict tables reject it; flexible collections match nothing

Whether an unknown where field is an error or a silent empty result depends on the schema policy of what you are querying:

typeunknown field in wherewhy
"table" (strict)400 β€” no such field 'x' in the where clause. This is a strict (type:table) schema, so an unknown field is rejected rather than silently ignoredthe schema is fixed, so an unknown name is a mistake
"collection" (flexible)200 with total: 0the schema grows with the data, so an unknown name is simply a column no row has yet

The flexible case is the one that costs debugging time: a typo returns a perfectly valid empty result, indistinguishable from "nothing matched". If a query on a collection returns total: 0 unexpectedly, check the field names against GET /api/v2/schema/{table} before assuming the data is missing.

A dotted path is different on both kinds of table: a path that does not resolve through the schema β€” a segment the linked table does not have, or a hop through a field that is not a link β€” is a typo in the path, not a column no row has yet, so it is refused with 400, naming the segment that failed and the fields available at that hop:

where: no such path 'company_id.NOPE': 'NOPE' is not a field of 'company_id'; its fields are: company_id, industry, name.

The same holds for a misspelt column inside a $refs filter ({ "$refs.contacts.company_id": { "$exists": { "rol": "CTO" } } } names rol and the referring table). select and orderBy already refused an unresolvable path.

A path continues through a self-link (a column whose link is this.<key>, such as prev β†’ this.id) like any other link: prev.user.tags and prev.prev.weekday resolve; see Multi-hop paths. On v2.11.1 and earlier such a path is refused the same way, naming prev.

Available since: v2.10.3. On v2.10.2 and earlier an unresolvable dotted path in where, or a misspelt column in a $refs filter, returned 200 with total: 0.

Available since: v2.7.0 for the strict rejection to apply to pre-existing tables β€” those created through the v1 path and later queried through v2, i.e. the whole population of a v1β†’v2 migration. Up to v2.6.1 an unknown field on such a table returned 200 total=0 even though the same query on a freshly-created type:"table" was correctly rejected. If you migrated against an older build and relied on that silence, expect those queries to start failing loudly β€” which is the point: they were returning wrong results, not empty ones.

Available since: v2.11.9. Earlier builds refuse the same queries with a message that does not name the cause: an empty field list ("its fields are: "), "field not found", "could not be opened", or an internal class name.

Error: 400, e.g.

path 'context.user': hop 1 'context' links 'impressions' (v1 engine) to 'contexts' (v2 engine),
and a link cannot cross engines. Migrate 'impressions' to v2: POST /api/v2/jobs/data/_modify
with {"operations": [{"migrate": "impressions", "engine": "v2"}]}

Cause: one table of a linked pair has been migrated to the v2 engine and the other has not. A link does not cross engines, in either direction, so a query that follows it is refused. That covers a path through the link (where, select, relate, a recommend goal), and on the v1 API also a predict, recommend or match of the link value itself, where the v1 engine opens the linked table. Queries that do not follow the link keep working.

Fix: migrate the table the message names, or migrate all linked tables together in one _modify request. The jobs/ form returns at once, so a large table does not hit the request timeout.

Unknown table

Error: 404 with the code not_found. A query names the table in the message ("invoicess not found"); the data and schema endpoints say "No such table: invoicess":

{ "kind": "error", "data": { "code": "not_found", "message": "invoicess not found" } }

It is usually a misspelt name, or a query sent before the table was created. On a fresh local container no table exists yet, so a copied _predict fails this way until you create the table and insert rows.

  • Table names are case-sensitive.
  • List tables: GET /api/v2/schema.
  • Ensure the table was created before querying.

Type mismatch

Error: Type mismatch: expected 'expectedType', got 'actualType'

Use the JSON type the column declares:

// Wrong β€” price is a number column
{ "from": "products", "where": { "price": "3.95" } }
// Correct
{ "from": "products", "where": { "price": 3.95 } }

Strings for Text/String, numbers for Int/Decimal, true/false for Boolean, arrays for Array/Set.

_similarity errors

POST /api/v2/_similarity exists in v2. Earlier versions of this page said it returns 404; it does not. A mistake in the body is a 400 that names the problem, for example missing 'from' (table name) for an empty body. A 404 with "code": "not_found" means the path itself is wrong, such as a typo in the endpoint name.

A valid call names the table and a {field: text} probe to rank rows against:

POST /api/v2/_similarity
{
  "from": "products",
  "similarity": { "name": "lactose free milk" },
  "select": ["name", "$score"],
  "limit": 3
}
{
  "offset": 0,
  "total": 42,
  "hits": [
    { "$score": 8.28, "name": "Pirkka lactose-free semi-skimmed milk drink 1l" },
    { "$score": 7.31, "name": "Valio eilaβ„’ Lactose-free semi-skimmed milk drink 1l" },
    { "$score": 1.91, "name": "Pirkka Finnish nonfat milk 1l" }
  ]
}

To combine similarity with other conditions, rank by it inside an ordinary query with orderBy, next to any where:

{
  "from": "products",
  "where": { "price": { "$lt": 3 } },
  "orderBy": { "$similarity": { "name": "coffee" } },
  "limit": 10
}

No test set on collection evaluate

Error: 400 β€” evaluate on a collection with neither test nor testSource.

Cause: unlike v1 tables, a v2 collection evaluate has no implicit default test set. Omitting both is rejected rather than silently sampling 100 rows, so you never measure something you didn't scope.

// Wrong β€” nothing scopes the test set
{ "evaluate": { "from": "products", "predict": "category" } }
// Correct β€” a deterministic 20% fold
{ "test": { "$index": { "$mod": [5, 0] } },
  "evaluate": { "from": "products", "where": { "name": { "$get": "name" } },
                "predict": "category" } }

See the Evaluation guide (v2).

Unknown inference profile

Error: 4xx β€” Unknown config.ai profile 'name'.

Cause: config.ai accepts fast (flat), and (v1), group (v2, the default), and high. An unrecognised name is rejected loudly, never silently ignored β€” so a typo can't quietly downgrade your inference.

// Wrong
{ "from": "products", "predict": "category", "config": { "ai": "groupp" } }
// Correct
{ "from": "products", "predict": "category", "config": { "ai": "group" } }

Unsupported computed column

Error: 4xx describing the unsupported expression.

Cause: a computed / derived column expression that the v2 engine doesn't support fails loud rather than resolving to an empty value. This is by design β€” a silent empty here would read as "no match" and mask the real cause.

Empty candidate set

Error: Empty candidate set / No matching rows.

Your filters matched nothing:

  • Broaden the criteria, or verify the filtered values exist.
  • Use $match for fuzzy text instead of exact match:
// Exact match may return nothing
{ "from": "products", "where": { "name": "coffee" } }
// Fuzzy
{ "from": "products", "where": { "name": { "$match": "coffee" } } }

Empty $search / $knn. Multi-term $search and $knn/$nn match all query tokens (AND). A term that appears in no row empties the whole result β€” near: "organic milk" returns nothing if "organic" is in no name, and $search "return policy refund" returns nothing if no single row has all three. Use OR or single terms ("$search": "return OR refund"), or the ranked search block, which orders by relevance instead of requiring every token: { "from": "prompts", "search": { "field": "prompt", "text": "refund" } }. Note that $search/$knn apply only to tokenized Text columns, not exact String columns.

Unknown operator

Error: Unknown operator '$operatorName'

  • Operators are case-sensitive and start with $.
  • Common v2 operators: $match (full-text), $has (Set/Array contains), $gt/$gte/$lt/$lte, $and/$or/$not, $numeric, $similarity, $get, $context, $why.
  • See the Query Operator Reference (v2).

Invalid JSON syntax

Error: Invalid JSON at line X, column Y

Usual suspects: missing commas, trailing commas, single quotes, unquoted strings, unbalanced brackets.

// Wrong β€” trailing comma
{ "from": "products", "where": { "name": { "$match": "coffee" }, } }
// Correct
{ "from": "products", "where": { "name": { "$match": "coffee" } } }

Content after the body

Available since: v2.11.8. On v2.11.7 and earlier, content after a body was ignored.

A request body is one JSON value. Anything but whitespace after it, such as stray text or a second copy of the body, is refused with a 400 (json.malformed on v2) instead of being answered as its first value. In a v2 NDJSON stream, a line with content after its object refuses the whole request, and nothing is written.

Duplicate keys

Error: json.malformed: Duplicate field 'columns' (line X, column Y)

A JSON object with the same key twice is refused by the schema routes (PUT /schema, PUT /schema/{table}, PUT /schema/{table}/{column}, _copy, _plan, _apply, _migrate, and their v1 counterparts). (A query with a duplicate key is refused too, as query.invalid with "duplicate field".) It applies at any depth: columns twice, the same column name twice, or type twice in one column. A JSON reader keeps only one of the values, so such a schema used to be accepted with columns silently missing; it is now a 400 that names the key. Send each key once, and merge column sets before sending:

// Wrong β€” "columns" twice: only the second set would have been kept
{ "type": "collection", "columns": { "id": { "type": "Int" } }, "columns": { "name": { "type": "String" } } }
// Correct
{ "type": "collection", "columns": { "id": { "type": "Int" }, "name": { "type": "String" } } }

Available since: v2.11.8 for the schema routes. Earlier builds accept a duplicate key in a schema body and keep the last value.

Query timeout

Error: Query timeout / Request timeout

  • Add limit to bound the result set.
  • Use more specific where conditions.
  • After heavy writes, optimize the table and warm it β€” see Warming Strategies (v2).

_predict returns a flat / uniform distribution

Not an error, but a common surprise: every candidate comes back at ~1/N (e.g. 0.02 across 50 values). The evidence isn't discriminating β€” usually a high-cardinality target with only a few examples per value and only diffuse text signal, so there isn't enough per-value evidence to move off the prior.

  • For open-ended "which known answer / article / template", use ranked search retrieval over the resolved history instead of predict (see Support Triage).
  • Reserve predict for lower-cardinality targets (type, category, route, assignee) where each class has many examples.
  • Add "$why" β€” if the factor tree shows only a base rate and no evidence factors, the evidence was below the detection threshold.

Values look wrong after an engine upgrade

If a column reads back implausibly β€” a boolean that's all-true, a text column that lost its analysis, links that resolve empty β€” the data may have been ingested by a build that predated the relevant fix. Some fixes are on the write path and do not repair already-stored data. Re-ingest the environment and re-check before assuming a live engine bug.