Running a Query via the API
This guide is for opteryx.app, the hosted service — not the opteryx-core library. If you're embedding the engine in your own Python process instead, see Querying Local Data.
Running a query against the hosted service is three calls: get an access token, submit the job, then poll for its result.
1. Get an Access Token
Exchange a client ID and secret for a bearer token, using the client_credentials grant:
curl -X POST https://authenticate.opteryx.app/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials' \
-d 'client_id=YOUR_CLIENT_ID' \
-d 'client_secret=YOUR_CLIENT_SECRET'{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYwNzA3IiwidHlwIjoiSldUIn0...",
"token_type": "bearer",
"expires_in": 300,
"refresh_token": null
}access_token is a short-lived JWT — expires_in is in seconds and varies by account, so re-authenticate rather than assuming a fixed lifetime. Use it as a bearer token on every subsequent call. See Authentication API for how to create and revoke client credentials.
2. Submit the Job
Send the SQL as sql_text in the body of a POST to the Jobs API:
curl -X POST https://jobs.opteryx.app/api/v1/jobs \
-H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYwNzA3IiwidHlwIjoiSldUIn0...' \
-H 'Content-Type: application/json' \
-d '{
"sql_text": "SELECT id, name, mass FROM opteryx.test.planets LIMIT 5"
}'{
"execution_id": "20260707191803-cwt7cvre22nqymzd",
"status": "SUBMITTED",
"created_at": null,
"status_url": "https://jobs.opteryx.app/api/v1/jobs/20260707191803-cwt7cvre22nqymzd/status"
}created_at is not populated at submission time — read it back from the status or results response once the job has run. status values are upper case (SUBMITTED, COMPLETED, FAILED).
The request body accepts a parameters field, but at present it is not wired up to :name (or @name) placeholders in sql_text — a query with an unresolved placeholder fails with ParameterError: Unresolved parameter in query, regardless of what's in parameters. Until that's fixed, the working pattern is a SET statement ahead of the query, in the same sql_text batch:
{
"sql_text": "SET @min_mass = 100; SELECT name FROM opteryx.test.planets WHERE mass > @min_mass;"
}
WarningThis embeds the value directly into
sql_text, so it is only safe for values your own code has coerced to a known scalar type (cast toint/float/a validated date) before building the string — never for raw, unescaped user input. A string value assigned this way can still break out of the statement (e.g. via an embedded quote or semicolon), so treat it with the same care as any hand-built SQL string. This is a stopgap for a feature that isn't fully wired up yet, not a substitute for real bound parameters.
3. Poll for Completion
Jobs run asynchronously. Poll status_url (or construct it from execution_id) until status is no longer SUBMITTED:
curl https://jobs.opteryx.app/api/v1/jobs/20260707191803-cwt7cvre22nqymzd/status \
-H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYwNzA3IiwidHlwIjoiSldUIn0...'{
"execution_id": "20260707191803-cwt7cvre22nqymzd",
"status": "COMPLETED",
"finished_at": "2026-07-07T19:18:06.141000Z",
"results_url": "https://jobs.opteryx.app/api/v1/jobs/20260707191803-cwt7cvre22nqymzd/results",
"error_message": null
}If status is FAILED, error_message describes what went wrong.
4. Get the Results
num_rows is required to be between 100 and 10000 inclusive — there's no way to ask for a smaller page:
curl 'https://jobs.opteryx.app/api/v1/jobs/20260707191803-cwt7cvre22nqymzd/results?num_rows=100' \
-H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYwNzA3IiwidHlwIjoiSldUIn0...'The response carries execution metadata — how many rows and bytes were processed, planning and execution time — plus the data itself. data is columnar, not a list of row objects: it's one entry per column, each with a name, a type, and a values array holding every row's value for that column:
{
"execution_id": "20260707191803-cwt7cvre22nqymzd",
"sql_text": "SELECT id, name, mass FROM opteryx.test.planets LIMIT 5",
"submitted_by": "bastian",
"total_rows": 5,
"bytes_processed": 0,
"time_taken_s": 0.0,
"data": [
{
"name": "id",
"type": "INT64",
"values": [1, 2, 3, 4, 5]
},
{
"name": "name",
"type": "VARCHAR",
"values": ["Mercury", "Venus", "Earth", "Mars", "Jupiter"]
},
{
"name": "mass",
"type": "FLOAT64",
"values": [0.33, 4.87, 5.97, 0.642, 1898.0]
}
],
"next_page": null
}To turn this into rows, zip the values arrays together by position — data[i].values[n] for every column i is the n-th row. Most client code reaches for a small helper to do this once rather than repeating it inline:
def to_rows(data):
columns = [c["name"] for c in data]
return [dict(zip(columns, values)) for values in zip(*(c["values"] for c in data))]Page through a larger result with num_rows and offset (watch the 100–10000 bound on num_rows), or use the download endpoint to get the full result as a single CSV or JSON-lines file — see Jobs API for both.
Estimating Cost Before Running
For a query you suspect is expensive, POST /api/v1/jobs/estimate returns a coarse byte estimate without executing it — useful before running something against a large dataset:
curl -X POST https://jobs.opteryx.app/api/v1/jobs/estimate \
-H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYwNzA3IiwidHlwIjoiSldUIn0...' \
-H 'Content-Type: application/json' \
-d '{ "sql_text": "SELECT * FROM opteryx.test.planets" }'{ "bytes": 0 }