Query API
Each running ReflexDB instance exposes a single query endpoint. Queries are plain-text selections sent as a POST body.
Endpoint
Section titled “Endpoint”POST https://<instance-id>.reflexdb.cloud/queryAuthentication
Section titled “Authentication”Pass your API key as a Bearer token:
Authorization: Bearer rxk_<keyId>.<hmac>API keys are created in the dashboard (Database → API Keys).
Making requests
Section titled “Making requests”A query is the plain-text body of a POST /query. Send it with anything that speaks HTTP:
curl https://<instance-id>.reflexdb.cloud/query \ -H "Authorization: Bearer rxk_<keyId>.<hmac>" \ --data-binary "users(plan = 'pro') { id name email }"const res = await fetch("https://<instance-id>.reflexdb.cloud/query", { method: "POST", headers: { Authorization: "Bearer rxk_<keyId>.<hmac>", "Content-Type": "text/plain", }, body: "users(plan = 'pro') { id name email }",});
if (!res.ok) { const { error } = await res.json(); // { code, message } throw new Error(`${error.code}: ${error.message}`);}
const { data, meta } = await res.json();console.log(data, meta.pagination);import requests
res = requests.post( "https://<instance-id>.reflexdb.cloud/query", headers={"Authorization": "Bearer rxk_<keyId>.<hmac>"}, data="users(plan = 'pro') { id name email }".encode(),)res.raise_for_status()payload = res.json()print(payload["data"], payload["meta"]["pagination"])Request format
Section titled “Request format”The request body is plain text (not JSON). The Content-Type header is not required.
<table> { <fields> }Select fields
Section titled “Select fields”users { id name email }Strict fields and @lenient
Section titled “Strict fields and @lenient”By default the API is strict: a query that names a field the schema doesn’t have returns 400 executor_error rather than silently ignoring it. This catches typos before they turn into silently-wrong results.
To opt out for selected fields — dropping unknown ones instead of erroring — prefix the query with the @lenient pragma:
@lenient users { id name nonexistent }@lenient only relaxes the selected field list. An unknown field in a filter or ORDER BY clause always returns executor_error in both modes, because ignoring it would silently change which rows you get back or their order.
Wildcards
Section titled “Wildcards”Use * to select all scalar fields, or ... to select all scalars plus one level of relations:
users { * }users { ... }Wildcards can be combined with explicit fields — explicit fields override wildcard-generated ones:
users { * posts { id title } }Filter
Section titled “Filter”Filter by any column using a SQL-style predicate in parentheses:
users(plan = 'pro') { id name email }Supported operators: =, !=, <, <=, >, >=, LIKE, ILIKE, IN (...), IS NULL, IS NOT NULL, BETWEEN x AND y.
LIKE/ILIKE match on Unicode characters: _ matches one character (not one byte), and ILIKE is case-insensitive across ASCII, accented Latin, Greek, and Cyrillic (e.g. ILIKE 'café' matches CAFÉ). Case folding is per-character and does not apply Unicode normalization, so a precomposed é and a decomposed e+accent are treated as different.
Boolean literals true and false are supported:
posts(published = true) { id title }Multiple conditions can be combined with AND and OR. Parentheses override precedence:
posts(published = true AND user_id = 42) { id title }users(plan = 'pro' OR (plan = 'free' AND verified = true)) { id name }Relation filters
Section titled “Relation filters”Filter parent rows by conditions on related tables using dot notation:
users(posts.published = true) { id name }Dot chains can traverse multiple levels:
users(posts.comments.approved = true) { id name }For to-many relations, ANY semantics apply — the parent matches if at least one related row satisfies the predicate.
Include
Section titled “Include”Embed related rows in a single request by selecting fields from a relation:
posts { id title author { id name } comments { id body } }Forward relations (FK on this table) return an object; reverse relations return an array.
[ { "id": 1, "title": "Hello world", "author": { "id": 42, "name": "Alice" }, "comments": [ { "id": 100, "body": "Great post!" } ] }]Relations can be nested to any depth and filtered independently:
users { id posts(published = true) { id title } }Add an ORDER BY clause after the closing brace:
posts { id title } ORDER BY created_at DESCSort order is ASC (default) or DESC. Multiple sort fields are separated by commas:
posts { id title } ORDER BY published_at DESC, id ASCNested relations support their own ORDER BY:
users { id posts { id title } ORDER BY title ASC }Pagination
Section titled “Pagination”Add LIMIT and/or OFFSET after the closing brace. Clauses must appear in this order: ORDER BY → LIMIT → OFFSET.
users { id name } ORDER BY id LIMIT 20 OFFSET 40| Clause | Default | Notes |
|---|---|---|
LIMIT | 1000 | Prevents accidental full-table dumps |
OFFSET | 0 | — |
Nested relations also support LIMIT and OFFSET:
users { id posts { id title } ORDER BY title ASC LIMIT 5 }Cursor pagination (keyset)
Section titled “Cursor pagination (keyset)”OFFSET pagination can skip or repeat rows if the underlying data changes between page requests — rows shift position as others are inserted or deleted. For a stable, consistent scan of a large result set, use keyset pagination with the AFTER clause instead.
Keyset pagination walks the result in primary-key order and requires the table to have a single-column primary key (integer or text — UUID and varchar keys work). Tables with a composite or missing primary key return 400 for AFTER, and AFTER cannot be combined with aggregate functions. Start with an empty cursor, then pass each response’s meta.pagination.next_cursor to the next request:
# first pageusers(active = true) { id name } LIMIT 100 AFTER ''
# next page — feed back next_cursor from the previous responseusers(active = true) { id name } LIMIT 100 AFTER '4821'Each row is returned exactly once even if rows are inserted or removed between pages. When meta.pagination.next_cursor is absent, you’ve reached the end.
Aggregations
Section titled “Aggregations”ReflexDB supports COUNT, SUM, and AVG aggregation functions. Aggregates are specified inline in the field list.
Supported functions
Section titled “Supported functions”| Function | Output key | Notes |
|---|---|---|
COUNT(*) | count | Count of all rows |
COUNT(field) | count | Count of non-NULL values |
SUM(field) | sum_<field> | Sum of a numeric column |
AVG(field) | avg_<field> | Average of a numeric column |
Basic aggregation
Section titled “Basic aggregation”orders { COUNT(*) }[{"count": 1482}]GROUP BY (implicit)
Section titled “GROUP BY (implicit)”Any non-aggregate field in the selection is an implicit GROUP BY key:
orders { status COUNT(*) }[ {"status": "pending", "count": 42}, {"status": "shipped", "count": 1440}]Multiple aggregates
Section titled “Multiple aggregates”orders { SUM(total_cents) AVG(total_cents) }[{"sum_total_cents": 9840200, "avg_total_cents": 6640}]With filter and sort
Section titled “With filter and sort”orders(status = 'shipped') { user_id SUM(total_cents) } ORDER BY total_cents DESC LIMIT 10Nested aggregation
Section titled “Nested aggregation”Aggregate functions also work inside embedded relations — useful for counting child rows per parent:
users { id name posts { COUNT(*) } }[ {"id": 1, "name": "Alice", "posts": [{"count": 12}]}, {"id": 2, "name": "Bob", "posts": [{"count": 3}]}]Response format
Section titled “Response format”A successful response is a JSON envelope with data and meta keys:
{ "data": [ { "id": 1, "name": "Alice", "email": "alice@example.com" }, { "id": 2, "name": "Bob", "email": "bob@example.com" } ], "meta": { "table": "users", "query": { "fields": ["id", "name", "email"] }, "pagination": { "count": 2, "total_matched": 2, "has_more": false, "limit": 1000 }, "timing_ms": 0.42, "snapshot": { "swap_count": 42, "age_ms": 1200 }, "version": "v0.32.1-390a3b19" }}meta.query.limit is echoed only when you set LIMIT explicitly (the injected default is not echoed there; the effective limit always appears under meta.pagination). When paginating with AFTER, meta.pagination.next_cursor carries the token for the next page (absent once you reach the end):
{ "meta": { "pagination": { "count": 100, "has_more": true, "next_cursor": "4821" } }}data— array of matching rowsmeta.table— the queried table namemeta.query— echo of the parsed query parameters (fields, aggregates, filter, order_by, limit, offset — only present when specified)meta.pagination.count— number of rows in this pagemeta.pagination.total_matched— total rows matching the filter before limit/offsetmeta.pagination.has_more—trueif more rows exist beyond this pagemeta.timing_ms— server-side query execution time in millisecondsmeta.snapshot—{ swap_count, age_ms }identifying the in-memory snapshot this response was served from.swap_countincrements on every refresh; compare it across requests to detect that data was refreshed between them.meta.version— engine version (<release>-<build hash>), also returned in theX-Reflex-Versionresponse header
Caching with GET and ETags
Section titled “Caching with GET and ETags”POST /query is the primary endpoint, but the same query can be issued as GET /query?q=<url-encoded query> for HTTP-cacheable reads. A GET response carries an ETag derived from the instance boot, the snapshot generation, and the query; send it back as If-None-Match (single tag, a comma-separated list, or W/-prefixed weak tags all work) and, if the snapshot hasn’t refreshed and the instance hasn’t restarted, you get 304 Not Modified with no body — no re-scan, and intermediary caches can serve the repeat. Treat the ETag as opaque:
GET /query?q=users%20%7B%20id%20name%20%7D→ 200, ETag: "18c2a4f1e9b0-42-1734..."
GET /query?q=users%20%7B%20id%20name%20%7D (If-None-Match: "18c2a4f1e9b0-42-1734...")→ 304 Not ModifiedUse GET for small, frequently-repeated reads; use POST for large or complex queries that exceed URL length limits.
Data types
Section titled “Data types”ReflexDB maps source database columns to JSON types as follows:
| SQL type | JSON type | Example |
|---|---|---|
INT, BIGINT, SMALLINT, TINYINT | number | 42 |
FLOAT, DOUBLE, REAL | number | 3.14 |
DECIMAL, NUMERIC | number | 99.99 |
BOOLEAN, BIT(1), TINYINT(1) | boolean | true |
CHAR, VARCHAR, TEXT | string | "hello" |
JSON, JSONB | string | "{\"key\":\"value\"}" |
UUID, INET, MACADDR | string | "550e8400-..." |
ENUM | string | "active" |
DATE | string | "2024-06-15" |
DATETIME, TIMESTAMP, TIMESTAMPTZ | string | "2024-06-15T12:30:00Z" |
TIME, TIMETZ | number (seconds) | 45000 |
INTERVAL | string | "1 year 2 months" |
BINARY, VARBINARY, BLOB, BYTEA | string (hex) | "deadbeef" |
| Nullable columns | null when NULL | null |
OpenAPI spec
Section titled “OpenAPI spec”Each instance also serves its own OpenAPI spec describing the exact tables and fields in its compiled schema:
GET https://<instance-id>.reflexdb.cloud/openapi.jsonVersioning
Section titled “Versioning”The query API is not URL-versioned — there is no /v1 path prefix. Each ReflexDB instance runs a single engine build serving one API contract on its own domain (https://<instance-id>.reflexdb.cloud), so a path version like /v2 on the same host would be misleading: an instance never serves two contracts at once.
Engine changes roll out per instance on your next rebuild. Rather than versioning by URL, pin or compare versions by provisioning a separate instance — each one’s /openapi.json describes exactly the contract it currently serves, and every response reports the engine build in meta.version and the X-Reflex-Version header.
Error responses
Section titled “Error responses”All errors return a JSON envelope with a nested error object:
| HTTP | error.code | Meaning |
|---|---|---|
| 400 | parse_error | Malformed query syntax |
| 400 | executor_error | Valid syntax but an unknown field (unless @lenient), or an invalid query structure |
| 404 | unknown_table | Table name not found in the schema |
| 413 | payload_too_large | Query body exceeds the configured size limit |
| 401 | unauthorized | Missing or invalid API key |
| 503 | — | Instance is starting up or unhealthy (status document, not an error envelope) |
{ "error": { "code": "unknown_table", "message": "unknown table: 'foobar'" } }