Relations
Vincent Depassier · August 29, 2026
PraxQL — Nested Relations
Instead of making several API calls to fetch related data, PraxQL lets you express relations inline in select. The engine resolves them in batches, so there is no N+1 problem.
How relations work
When a column is of type Table, it already names the table it points at — /schema reports that as pointsTo. PraxQL uses it to work out the join on its own. You declare the relation in select as an object instead of a string:
{
"refs": {
"Customers": "aaa-guid",
"Orders": "bbb-guid"
},
"query": {
"from": "Customers",
"select": [
"Name",
"Email",
{
"table": "Orders",
"select": ["Total", "Date", "Status"]
}
]
}
}{
"data": [
{
"Name": "Acme Corp",
"Email": "hello@acme.com",
"Orders": [
{ "Total": 1500.00, "Date": "2026-03-01", "Status": "Completed" },
{ "Total": 800.00, "Date": "2026-04-15", "Status": "Pending" }
]
}
]
}Related records nest as arrays inside each parent row. They are never flattened into repeated parent rows, so you do not have to de-duplicate anything.
Every field in a relation object
Field | Required | Description |
| yes | Alias of the related table. Must exist in |
| no | Columns to return from this table |
| no | Filters applied to the related rows |
| no | Sort for the related rows |
| no | Max related rows per parent row |
| no | Further nested relations |
| no | Explicit join columns, overriding auto-detection |
| no |
|
Filtering and limiting a relation
{
"table": "Orders",
"select": ["Total", "Date", "Status"],
"where": [
{ "field": "Status", "op": "neq", "value": "Cancelled" }
],
"orderBy": [{ "field": "Date", "dir": "desc" }],
"limit": 5
}At most five non-cancelled orders per customer, newest first. Note that limit here is per parent row, not a total across the response.
Deep nesting
Nest relations inside relations with the relations key:
{
"refs": {
"Customers": "aaa-guid",
"Orders": "bbb-guid",
"OrderItems": "ccc-guid"
},
"query": {
"from": "Customers",
"select": [
"Name",
{
"table": "Orders",
"select": ["Total", "Date"],
"relations": [
{
"table": "OrderItems",
"select": ["ProductName", "Quantity", "UnitPrice"]
}
]
}
]
}
}{
"data": [
{
"Name": "Acme Corp",
"Orders": [
{
"Total": 1500.00,
"Date": "2026-03-01",
"OrderItems": [
{ "ProductName": "Widget A", "Quantity": 10, "UnitPrice": 150.00 }
]
}
]
}
]
}Auto-detected joins
PraxQL works out the join condition when either side points at the other:
the parent table has a
Tablecolumn whosepointsTois the child table, orthe child table has a
Tablecolumn whosepointsTois the parent table.
For a normal relationship column you never write on — the schema already knows the connection.
Explicit joins
When auto-detection picks the wrong column, or the relationship is not expressed as a Table column at all, name the columns yourself:
{
"table": "Invoices",
"on": { "left": "ID", "right": "CustomerRef" },
"joinType": "INNER",
"select": ["InvoiceNumber", "Amount"]
}Field | Description |
| Column in the parent table |
| Column in the child table |
|
|
Depth limit
There are two numbers here and they are easy to confuse.
5 is the platform ceiling. Nothing raises it.
2 is what a newly created table scope actually gets — MaxRelationDepthOverride starts at 2. So a fresh credential nests two levels deep, not five, until somebody widens it. An override can be moved anywhere up to 5, never past it.
Going past the effective limit returns 403 SCOPE_VIOLATION. If a query that works in the Playground fails from your app, this is the first thing to check.
The related table must also allow relations at all: AllowRelations is on by default on a new scope, but it can be switched off.
Why it is not N+1
PraxQL does not issue one query per parent row:
The primary query runs and returns the parent rows.
Their ids are collected.
One batched sub-query fetches every related row for that whole set of ids at once.
Results are stitched together before the response is built.
Fetching 100 customers with their orders is two queries, not 101. Each extra nesting level adds one query, not one per row.
Every table must be in refs
Every table used in a relation — including deeply nested ones — must be declared in the top-level refs with its GUID:
{
"refs": {
"Customers": "aaa-guid",
"Orders": "bbb-guid",
"OrderItems": "ccc-guid"
}
}A table referenced in a relation but missing from refs returns 400 INVALID_QUERY. This is deliberate: refs is the complete list of what a request may touch, so it can be checked against your scopes before any work is done.