QQL 0.4.0 hardens the compiler, query planner, and host SDKs, moves the language surface to QQL 1.7 (parameter placeholders, the WAIT durability clause, and typed shard keys), upgrades the Rust workspace to Edition 2024, and unifies batch parameter semantics across Python, Node.js, and WebAssembly.
Most statements and calls keep working unchanged. The sections below list what now fails closed, what behaves differently, and the replacement for each call shape. Every code named here is defined in the error code reference. To triage an unfamiliar statement before running it, use qql check "<statement>".
Breaking changes
Section titled “Breaking changes”These changes reject input or change output that 0.3.1 accepted. Update the call site shown with each item.
1. Typed shard keys end to end
Section titled “1. Typed shard keys end to end”Keyword and number shard keys hash differently in Qdrant. 0.4.0 carries the key type from parse through REST and gRPC: SHARD 101 routes to the numeric partition, and SHARD '101' routes to the keyword partition named 101.
Before 0.4.0, only UPSERT preserved numbers. The other update statements coerced a numeric key through a stringifying shim, query statements rejected numbers at parse time, the gRPC adapter wrapped every key as a keyword, and DROP SHARD KEY 101 did not parse at all. All of those cases are fixed.
What changed:
- All routing statements accept
SHARD <int>, andCREATE SHARD KEY 101,DROP SHARD KEY 101, and integershard_keysentries inWITH PARAMSkeep numeric form.DROP SHARD KEYaccepts quoted and numeric keys. SHARD :tenantbinds by value type: strings become keyword keys and non-negative integers become numeric keys.- AST JSON renders keys as
{"Keyword": "acme"}or{"Number": 101}. A numeric key is no longer rendered as a bare string. - Host accessors are typed: Python
stmt.shard_keyisstr | int, Nodestmt.shardKeyisstring | number | bigint, and the WASM getter returnsstring | bigintwhile its setter accepts a string, number, bigint, ornull. Setters fail closed on booleans, floats, negative values, and out-of-range magnitudes. RustStmt::shard_key()andPlannedOperation::shard_key()return typed keys. - The non-contract
?shard_key=REST query parameter is no longer emitted. Routing travels in the request body, which is where Qdrant defines it.
Fix: pass the key in its real type. In source, quote keyword keys and leave numeric keys bare. In host code, assign a string or an integer.
-- Numeric partition.QUERY [0.1, 0.2] FROM docs SHARD 101 LIMIT 5;
-- Keyword partition named "101" (a different partition).QUERY [0.1, 0.2] FROM docs SHARD '101' LIMIT 5;stmt.shard_key = 101 # numeric partitionstmt.shard_key = "acme" # keyword partitionstmt.shardKey = 101; // numberstmt.shardKey = 101n; // bigint, the same numeric partitionstmt.shardKey = "acme"; // keyword partitionIf any code compared a shard key against a string, handle numbers as well.
2. Empty scripts fail closed (QQL-VALIDATION-EMPTY-SCRIPT)
Section titled “2. Empty scripts fail closed (QQL-VALIDATION-EMPTY-SCRIPT)”execute(""), whitespace-only and comments-only strings, and an empty statement array now raise QQL-VALIDATION-EMPTY-SCRIPT instead of returning an empty { ok: true } report. analyze and explain_analyze raise the same code for an empty input. ";;" was already a parse error.
Why: an empty success report made "nothing ran" look like a completed script.
if not queries: returnclient.execute(queries)3. Statement-scoped batch parameters (QQL-BIND-BATCH-LENGTH)
Section titled “3. Statement-scoped batch parameters (QQL-BIND-BATCH-LENGTH)”In 0.3.x, passing lists of parameters to multi-statement batches had ambiguous broadcasting rules. In 0.4.0, batch parameter binding follows a single strict contract across Python, Node.js, and WebAssembly:
- Statement-scoped: A list whose items are all parameter containers (all dicts/objects or all lists/arrays) binds 1:1 to statements by index. The parameter list length must exactly match the statement count, or the runtime raises
QQL-BIND-BATCH-LENGTH. - Broadcast: A single dictionary/object, or an array that is not all containers (including a scalar list such as
[1, 2]), applies to every statement identically. - Single-container rule: Passing
params=[[1, 2]]to a single statement binds[1, 2]positionally to statement 0 (unrolled as a 1-element container list), never as a positional matrix.
# Statement-scoped: 2 statements, exactly 2 parameter dictionariesclient.execute( ["QUERY :v FROM coll_a", "QUERY :v FROM coll_b"], params=[{"v": [0.1, 0.2]}, {"v": [0.3, 0.4]}],)
# Broadcast: 2 statements, 1 shared dictionary applied to bothclient.execute( ["QUERY :v FROM coll_a", "QUERY :v FROM coll_b"], params={"v": [0.1, 0.2]},)4. Duplicate parameter collisions (QQL-BIND-DUPLICATE-PARAM)
Section titled “4. Duplicate parameter collisions (QQL-BIND-DUPLICATE-PARAM)”When flattening nested dictionaries into dotted parameter keys (such as loc.lat), QQL now rejects conflicting definitions instead of silently overwriting earlier values.
# FAILS with QQL-BIND-DUPLICATE-PARAM:client.execute( "QUERY [0.1, 0.2] FROM docs WHERE lat = :loc.lat", params={"loc": {"lat": 1.0}, "loc.lat": 2.0},)5. Canonical statement strings and previews
Section titled “5. Canonical statement strings and previews”Node and WASM Stmt.toString() now return canonical, re-parseable QQL instead of a truncated debug preview. Positional markers are normalized to a bare ?, and SCROLL LIMIT, FACET LIMIT, and formula targets render their placeholders correctly, so canonical output re-parses. Python keeps str(stmt) canonical and repr(stmt) as the truncated form.
Use Stmt.toReadableString() for the compact preview intended for logs.
// Canonical: re-parseableconst qql = stmt.toString();
// Preview: safe for logs, vector literals truncatedconsole.log(stmt.toReadableString());6. Strict zero-limit rejection
Section titled “6. Strict zero-limit rejection”Literal LIMIT 0 is rejected at parse time with QQL-PARSE-POSITIVE-INTEGER across QUERY, SCROLL, and FACET. FACET ... WITH (limit = 0) carries the same code, and a parameter that binds to 0 is rejected with QQL-BIND-TYPE-MISMATCH. OFFSET 0 remains valid.
Why: Qdrant's query API requires limit >= 1, so the runtime no longer sends a request the backend is guaranteed to reject.
-- REJECTED at parse/validation:QUERY [0.1, 0.2] FROM docs LIMIT 0;FACET category FROM docs WITH (limit = 0);7. Strict grammar surface
Section titled “7. Strict grammar surface”The parser now rejects inputs it used to tolerate:
| Input | Result |
|---|---|
Trailing commas in objects, lists, PARAMS, or config blocks | QQL-PARSE-TRAILING-COMMA |
Empty PARAMS () | QQL-PARSE-SEARCH-PARAMS |
ALTER COLLECTION without a WITH clause | QQL-PARSE-ALTER-CONFIG |
Unknown or duplicate FACET WITH keys | QQL-PARSE-FACET-CONFIG |
Numeric field names, non-scalar BETWEEN / IN bounds, float values for integer options such as m = 2.0, operator object keys | Parse failures with QQL-PARSE-* codes |
Unknown VECTOR, OPTIMIZERS, QUANTIZATION, or index options | QQL-VALIDATION-CONFIG (Validation) |
PARAMS and config failures carry token spans.
Why: keys that were silently ignored hid misspelled configuration. Remove the trailing comma, delete the empty block, add the missing WITH, or write the value in the expected shape.
8. Fail-closed filter injection on UPSERT (QQL-VALIDATION-FILTER-INJECT)
Section titled “8. Fail-closed filter injection on UPSERT (QQL-VALIDATION-FILTER-INJECT)”inject_filter on an UPSERT now reports QQL-VALIDATION-FILTER-INJECT when the operator is not = or the field is id. Before, the call was a silent no-op. On other statement types the previous fail-closed behavior is unchanged, and host string operators are parsed ASCII case-insensitively.
Why: a policy predicate that does not apply must not look successful. UPSERT can only stamp an equality payload key; it cannot express a point ID predicate.
stmt.inject_filter("tenant_id", "=", "acme") # OK
# QQL-VALIDATION-FILTER-INJECT:# stmt.inject_filter("rating", ">", 4)# stmt.inject_filter("id", "=", 42)Injecting into an unbound whole-point parameter (VALUES :rows) fails the same way; bind the points first, then inject. For point-targeted changes, put the selector in the statement itself.
9. Imperative executor helpers removed
Section titled “9. Imperative executor helpers removed”Executor::scroll_ids, Executor::upsert_records, and Executor::upsert_columns are gone from the Rust executor, and the SDK clients no longer expose ad-hoc imperative helpers. Use declarative QQL instead:
| Removed | Replacement |
|---|---|
scroll_ids | SCROLL FROM docs WHERE ... AFTER :offset LIMIT 100, or scroll_cursor / scrollCursor / scrollStream |
upsert_records / upsert_columns | UPSERT INTO docs VALUES :rows through upsert_many / upsertMany |
Why: one execution path keeps batching, validation, and error codes in the planner.
client.upsert_many("docs", rows, batch_size=100)for point in client.scroll_cursor("docs", batch_size=100): ...await client.upsertMany("docs", rows, { batchSize: 100 });for await (const point of client.scrollCursor("docs", { batchSize: 100 })) { ...}10. Removal of truncateVectors from execution options
Section titled “10. Removal of truncateVectors from execution options”The truncateVectors flag has been removed from client execute() and executeAsync() options. Vector truncation is strictly a display/inspection concern:
- Use
bind(query, params, { truncateVectors: true })(Node, WASM),bind(query, params, truncate_vectors=True)(Python), orStmt.toReadableString()for debugging. - Query execution always transmits full vector payloads.
11. Backdoor response fields removed
Section titled “11. Backdoor response fields removed”The execution result JSON no longer includes the internal __pre_serialized_hits and hits_len keys from the gRPC read path. Reports now extract points from the canonical Qdrant response structures directly.
Fix: read results through the report accessors (hits(), points(), ids(), facet(), count(), groups()) instead of parsing raw result JSON.
Why: two representations of the same hits could disagree, and the internal keys were never part of the contract.
12. Named backend error codes
Section titled “12. Named backend error codes”REST and gRPC failures now map to specific codes:
| Code | When |
|---|---|
QQL-BACKEND-AUTH | Rejected credentials (HTTP 401/403, gRPC Unauthenticated / PermissionDenied) |
QQL-BACKEND-COLLECTION-NOT-FOUND | Missing collection (HTTP 404, gRPC NotFound) |
QQL-BACKEND-DIMENSION-MISMATCH | Vector size disagrees with the collection schema |
QQL-BACKEND-INDEX-NOT-READY | The server index is still building, retry the query |
QQL-BACKEND-STRICT-MODE | Strict-mode or quota rejection |
QQL-BACKEND-HTTP | Other rejected REST requests, for example HTTP 400 on a batch |
QQL-BACKEND and QQL-GRPC remain for unclassified failures.
Fix: match the specific code. Authentication and missing-collection failures are permanent, while QQL-BACKEND-INDEX-NOT-READY is transient and safe to retry.
13. Parameter error code consolidation and exact spans
Section titled “13. Parameter error code consolidation and exact spans”The 0.3.x per-type codes for invalid integer, invalid point ID, invalid float, and formula parameter values no longer exist. Every parameter type failure now reports the single code QQL-BIND-TYPE-MISMATCH, including a bound LIMIT value of 0.
The other bind outcomes keep dedicated codes: QQL-BIND-MISSING-PARAM, QQL-BIND-UNBOUND-PARAM, QQL-BIND-MISSING-POSITIONAL, QQL-BIND-UNUSED-PARAMS, QQL-BIND-NULL-PARAM, QQL-BIND-INVALID-PARAMS, QQL-BIND-UNSUPPORTED-STATEMENT, and QQL-BIND-ALREADY-BOUND (new params on an already-bound Stmt).
Placeholder-level bind failures carry an exact Span byte range for the offending :name or ? occurrence. Structural failures such as a batch length mismatch carry no span.
Fix: match on QQL-BIND-TYPE-MISMATCH in type-error handling.
14. Rust Edition 2024
Section titled “14. Rust Edition 2024”The workspace is Edition 2024 with rust-version = "1.98". Building against the QQL crates requires a toolchain at or above that version. Your own crates keep their current edition.
Behavioral changes
Section titled “Behavioral changes”These items keep the same call shapes, but results, error codes, or wire requests may differ.
Point IDs and typed results
Section titled “Point IDs and typed results”Non-standard string point IDs are preserved as strings instead of being coerced to empty strings, and numeric IDs keep integer types. ScoredPoint exposes id, score, version, payload, vector, and shard_key; a missing score reads as 0.0. Python points also support dictionary indexing.
hits = report.hits(0)# hits[0].id is typed as Union[int, str]print(hits[0].id, type(hits[0].id))Float equality filters
Section titled “Float equality filters”WHERE rating = 4.5 now lowers to range(gte: 4.5, lte: 4.5) on REST and gRPC because Qdrant's match filter rejects float values at runtime. Integer and string equality still use match.
If a float match value still reaches the gRPC converter, it fails with QQL-GRPC-FLOAT-MATCH and points at a RANGE filter or an exact integer value instead of silently matching nothing.
Unscored retrieval accessors
Section titled “Unscored retrieval accessors”.hits() and .points() no longer drop points without a score, so SCROLL and QUERY POINTS return rows instead of []. Facet buckets stay out of hits() / points(): use .facet(), which returns { value, count } entries.
Why: browsing operations are not scored, and the old accessors made a successful retrieval look empty.
WAIT durability propagation
Section titled “WAIT durability propagation”QQL 1.7 adds a trailing WAIT true|false clause to UPSERT, DELETE, DELETE VECTOR, CLEAR PAYLOAD, DELETE PAYLOAD, UPDATE ... VECTOR, UPDATE ... PAYLOAD, and CREATE INDEX (after SHARD; a repeated WAIT is rejected with QQL-PARSE-DUPLICATE-CLAUSE). SET QUOTA already accepted it.
The gRPC path now honors WAIT false for upserts, deletes, updates, clear payload, and create index. Before 0.4.0 it always waited on those operations, so WAIT false did not reduce latency as requested.
UPSERT INTO docs VALUES {id: 1, text: 'a'} WAIT false;DELETE FROM docs WHERE status = 'stale' WAIT true;When the clause is omitted, Qdrant's default applies (wait until applied).
Batch behavior
Section titled “Batch behavior”- Same-collection
/points/query/batchresponses no longer drop points from the response envelope. DELETE PAYLOADbatches on REST, gRPC, and edge like the other update operations. REST projection failures now surface asQQL-PLAN-SERIALIZEinstead of panicking.- With
on_error = "continue", an unbound parameter is recorded as a step failure withoperation: "BIND"and earlier results are kept, instead of aborting the script and discarding them. WASM detects per-itemstatus: "error"entries in HTTP 200 batch responses and retries a failed batch individually when continuation is requested.
Hybrid sparse model leg
Section titled “Hybrid sparse model leg”Hybrid queries embed the sparse leg with the request's MODEL instead of silently falling back to "default". On WASM, a non-default dense MODEL that is not configured fails with QQL-EMBEDDING, and empty dense responses or empty embedding vectors are rejected on the query path.
Fix: configure the dense model in the embedder, or omit the MODEL clause to use the configured default.
Edge unbound placeholders fail closed
Section titled “Edge unbound placeholders fail closed”A vector or query placeholder that reaches edge execution unbound now fails with QQL-EDGE-VECTOR or QQL-EDGE-QUERY naming the placeholder, instead of executing with no value.
Fix: bind parameters before executing. The edge backend does not embed text or resolve placeholders itself.
Internal file layout and re-exports
Section titled “Internal file layout and re-exports”qql-core, qql-plan, qql-runtime, and qql-cli were split into focused modules under 400 lines (fmt/, params/, ast/statement/, parser/query/, executor ddl/ / batch / dispatch / prepared / response, CLI table/ and dump/). Public paths are re-exported, so documented imports keep working. Only code reaching into paths that are not part of the public API needs an update; import from the crate root or the documented module instead.
New in 0.4.0
Section titled “New in 0.4.0”No migration needed. These additions replace common workarounds.
- Prepared statements and parameter placeholders (
:nameand?) across query inputs, point IDs, scalars, and clauses, with nested key expansion (:loc.lat), typed-array vector params (Float32Array/Float64Arrayin Node, 1-D float buffers in Python), and whole-point upsert params (VALUES :rows). See Parameter binding. - Bulk ingest with
upsert_many/upsertMany, which prepares one:rowstemplate and splices each chunk.batch_sizebelow 1 fails withQQL-VALIDATION-UPSERT-BATCH. - Typed report accessors (
.hits(),.points(),.facet(),.count(),.groups()),ScoredPoint, execution telemetry on every report, and typedQqlErrorsubclasses in Python. - Lazy scroll cursors: Node
client.scrollCursor/client.scrollStreamand Pythonclient.scroll_cursor/scroll_cursor_async, buffering at most one page. - Execution profiling with
Executor::explain_analyze/Client.explain_analyze/Client.explainAnalyze, returning the static plan plus measured client phases and server telemetry. - WASM
dx.js:@veristamp/qql-wasmnow includes the typed wrapper module matching@veristamp/nqql:ExecutionReport,ScoredPoint,buildError,wrapReport,executeHits, andscrollCursorare available for browser and Edge runtimes with full TypeScript typings. - Python DB-API subset:
pyqql.connect("http://...")returns a PEP 249 connection withexecute,executemany,fetchone,fetchmany,fetchall,nextset, iteration,description, androwcount.commit()is a documented no-op;rollback()raisesNotSupportedError. - CLI:
--param key=value/--params-file <path>onqql runandqql explain, a\paramREPL command,qql doctor "<query>"(aliascheck) staged triage,qql migratefor cluster-to-cluster moves, and shardedqql dumpoutput that round-tripsCREATE SHARD KEYplus per-shardSHARD-routed upserts. - Embedder parity: Python
HttpEmbeddergains the multi, image, and rerank endpoint groups; WASM gainssetHttpMultiEmbedder,setHttpImageEmbedder, andsetHttpRerankerwith core fallback semantics. Sparse stays local BM25 on every host.