Skip to content

Query API

Each running ReflexDB instance exposes a single query endpoint. Queries are plain-text selections sent as a POST body.

POST https://<instance-id>.reflexdb.cloud/query

Pass your API key as a Bearer token:

Authorization: Bearer rxk_<keyId>.<hmac>

API keys are created in the dashboard (Database → API Keys).


A query is the plain-text body of a POST /query. Send it with anything that speaks HTTP:

Terminal window
curl https://<instance-id>.reflexdb.cloud/query \
-H "Authorization: Bearer rxk_<keyId>.<hmac>" \
--data-binary "users(plan = 'pro') { id name email }"

The request body is plain text (not JSON). The Content-Type header is not required.

<table> { <fields> }
users { id name email }

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.

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 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 }

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.

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 DESC

Sort order is ASC (default) or DESC. Multiple sort fields are separated by commas:

posts { id title } ORDER BY published_at DESC, id ASC

Nested relations support their own ORDER BY:

users { id posts { id title } ORDER BY title ASC }

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
ClauseDefaultNotes
LIMIT1000Prevents accidental full-table dumps
OFFSET0

Nested relations also support LIMIT and OFFSET:

users { id posts { id title } ORDER BY title ASC LIMIT 5 }

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 page
users(active = true) { id name } LIMIT 100 AFTER ''
# next page — feed back next_cursor from the previous response
users(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.


ReflexDB supports COUNT, SUM, and AVG aggregation functions. Aggregates are specified inline in the field list.

FunctionOutput keyNotes
COUNT(*)countCount of all rows
COUNT(field)countCount of non-NULL values
SUM(field)sum_<field>Sum of a numeric column
AVG(field)avg_<field>Average of a numeric column
orders { COUNT(*) }
[{"count": 1482}]

Any non-aggregate field in the selection is an implicit GROUP BY key:

orders { status COUNT(*) }
[
{"status": "pending", "count": 42},
{"status": "shipped", "count": 1440}
]
orders { SUM(total_cents) AVG(total_cents) }
[{"sum_total_cents": 9840200, "avg_total_cents": 6640}]
orders(status = 'shipped') { user_id SUM(total_cents) } ORDER BY total_cents DESC LIMIT 10

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}]}
]

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 rows
  • meta.table — the queried table name
  • meta.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 page
  • meta.pagination.total_matched — total rows matching the filter before limit/offset
  • meta.pagination.has_moretrue if more rows exist beyond this page
  • meta.timing_ms — server-side query execution time in milliseconds
  • meta.snapshot{ swap_count, age_ms } identifying the in-memory snapshot this response was served from. swap_count increments 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 the X-Reflex-Version response header

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 Modified

Use GET for small, frequently-repeated reads; use POST for large or complex queries that exceed URL length limits.


ReflexDB maps source database columns to JSON types as follows:

SQL typeJSON typeExample
INT, BIGINT, SMALLINT, TINYINTnumber42
FLOAT, DOUBLE, REALnumber3.14
DECIMAL, NUMERICnumber99.99
BOOLEAN, BIT(1), TINYINT(1)booleantrue
CHAR, VARCHAR, TEXTstring"hello"
JSON, JSONBstring"{\"key\":\"value\"}"
UUID, INET, MACADDRstring"550e8400-..."
ENUMstring"active"
DATEstring"2024-06-15"
DATETIME, TIMESTAMP, TIMESTAMPTZstring"2024-06-15T12:30:00Z"
TIME, TIMETZnumber (seconds)45000
INTERVALstring"1 year 2 months"
BINARY, VARBINARY, BLOB, BYTEAstring (hex)"deadbeef"
Nullable columnsnull when NULLnull

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.json

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.


All errors return a JSON envelope with a nested error object:

HTTPerror.codeMeaning
400parse_errorMalformed query syntax
400executor_errorValid syntax but an unknown field (unless @lenient), or an invalid query structure
404unknown_tableTable name not found in the schema
413payload_too_largeQuery body exceeds the configured size limit
401unauthorizedMissing or invalid API key
503Instance is starting up or unhealthy (status document, not an error envelope)
{ "error": { "code": "unknown_table", "message": "unknown table: 'foobar'" } }