Skip to content

GraphQL

A single shared GraphQL endpoint serves queries and mutations across every model, translated internally into the same CRUD operations REST uses – same access control, same row-level security.

Models marked "internal": true are not reachable from a root field over HTTP, exactly like the REST routes (a NOT_FOUND error). A relationship field can still join one: selecting user_id { id email } on a public model works when users is internal, under the joined model’s field-level read rules. Python code calling kirak.graphql() reaches every model, as it does with kirak.fetch().

POST /graphql

{
"query": "{ posts(status: \"published\", limit: 10) { id title created_at } }"
}

GraphQL uses the same response envelope as REST. It is not spec-compliant GraphQL-over-HTTP: there is no top-level errors[].

Success:

{
"statusCode": 200,
"status": "success",
"message": "GraphQL executed successfully",
"data": { "posts": [ { "id": 1, "title": "Hello" } ] }
}

Error (rendered with the failing operation’s own status code):

{
"statusCode": 403,
"status": "error",
"error": "PERMISSION_DENIED",
"message": "Access denied for 'secrets'",
"data": null
}

Any error in any root field fails the whole request – there is no partial data with a per-field error entry. An unknown model is NOT_FOUND (404), matching REST. An unknown mutation verb is VALIDATION_ERROR (400). A syntactically malformed query is also VALIDATION_ERROR (400) (Invalid GraphQL syntax: ...). Any other unhandled error surfaces as INTERNAL_ERROR (500).


GraphQL fires the same per-model CRUD hooks as REST:

  • Mutations – before_<op> and after_<op> fire on the model for each mutation root field. createPosts fires before_create/after_create on "posts".
  • Simple queries – before_fetch/after_fetch for plain field queries; before_search/after_search when a search: argument is used.

In addition, two request-level hooks wrap the entire GraphQL operation and are registered on the "graphql" model name:

@kirak.on("graphql").hook("before_graphql")
async def inspect_query(payload):
# payload = {"query": "...", "variables": {...}}
return payload
@kirak.on("graphql").hook("after_graphql")
async def post_process(result):
# result = full response envelope
return result

before_graphql fires before query translation. after_graphql fires after all root fields execute (and, for mutations, after the transaction commits).

Relationship join queries and aggregate queries (<model>_aggregate) are GraphQL-only paths with no REST equivalent; they fire before_graphql/after_graphql but not per-model CRUD hooks.


A mutation with more than one root field runs all root fields inside one database transaction, serially. If any field fails, the entire mutation rolls back – earlier fields are not committed. GraphQL queries (reads) do not open a transaction.


A plain argument value is equality. Use an object argument for other operators:

{
posts(status: "published", price: { gt: 100, lte: 500 }) {
id
title
}
}
Operator key SQL
(plain value) =
eq =
neq !=
gt / gte > / >=
lt / lte < / <=
like / ilike LIKE / ILIKE (PostgreSQL native; MySQL falls back to LOWER(...) LIKE LOWER(...))
in / nin IN (...) / NOT IN (...)
is_null IS NULL / IS NOT NULL depending on boolean value

This is a different convention from REST’s field__operator query-string keys – GraphQL always nests the operator inside an object argument.

Dates are ISO-8601 strings, as in REST: created_at: { gte: "2026-09-01" }. A string that is not a date on a date or datetime field is a 400 VALIDATION_ERROR.

Plain field-filter queries (no search: argument, see below) delegate internally to the same _fetch function REST’s /fetch route uses, so the RLS rules that apply are the model’s fetch rules specifically. A query using search: delegates to _search instead and is scoped by the model’s search rules. For a model whose access block defines identical rules for both fetch and search (the common case – Kirak Studio generates them together), this distinction is invisible; a model with genuinely different rules per operation will see different results depending on whether search: was used.

Add a search: argument to route a query through the model’s searchable fields ("searchable": true in models.json) instead of exact/operator filtering:

{
posts(search: "kirak") {
id
title
}
}

This is the GraphQL equivalent of REST’s /search route and its search_term: a LIKE-across-searchable-fields match, OR’d together. search: can be combined with regular field filters in the same query – both apply. A model with no field marked searchable returns a permission error if search: is used against it, the same as REST’s /search route would.

{
posts(limit: 10, offset: 20, orderBy: "-created_at") {
id
title
}
}

limit, offset, orderBy (or order_by), and search are reserved argument names on every model field – they are never treated as filters even if a model happens to have a field with the same name.

A query with no limit: argument is capped at 10 rows by default (or the model’s max_limit if configured in models.json, whichever is smaller), the same default REST’s /fetch and /search routes use – it is not unbounded.

A field can resolve a joined model if its schema entry declares foreign_key: true plus a relationship object (see Defining Models):

{
"schema": {
"user_id": {
"type": "integer",
"foreign_key": true,
"relationship": { "model": "users", "foreign_key": "id" }
}
}
}

With that in place, selecting the field with a sub-selection produces a LEFT JOIN:

{
posts {
id
title
user_id {
id
email
}
}
}

Field-level read permissions on the joined model still apply to its nested selection.

Every model also exposes a <model>_aggregate root field:

{
orders_aggregate(status: "completed", groupBy: "user_id") {
count
sum { total }
avg { total }
min { total }
max { total }
}
}
  • count takes no sub-selection.
  • sum/avg/min/max take a sub-selection of the numeric fields to aggregate.
  • groupBy (or group_by) groups by one or more fields; without it, the whole filtered set is aggregated into one row.
  • limit, offset, orderBy (or order_by), and the same filter-operator syntax as regular queries are all supported.
  • Access is checked against the model’s search rules (not fetch), and their row conditions scope the rows aggregated.
  • Each aggregate comes back as <function>_<field>, e.g. sum { total } -> sum_total, next to the groupBy fields.

Mutation field names are {operation}{Model}, where {operation} is create/update/upsert/delete/destroy/restore and {Model} is the model name with each _-separated part capitalized: order_items -> createOrderItems, blogPost -> createBlogPost. If two model names give the same {Model} (blog_post and blogPost), the snake_case one gets it:

mutation {
createPosts(input: { title: "Hello", user_id: 1 }) {
id
title
}
updatePosts(where: { id: 42 }, input: { title: "Updated" }) {
id
title
}
deletePosts(ids: [1, 2, 3]) {
id
}
}
Operation Args Notes
create<Model> input Single record; the return selection is fetched by id after insert.
update<Model> input, where where uses the same filter syntax as queries.
upsert<Model> input Conflict detected on unique fields, same as REST.
delete<Model> ids or where Soft delete.
destroy<Model> ids or where Hard delete, irreversible.
restore<Model> input, where Un-soft-delete. input must contain at least one restore field (deleted_at, status, etc.), same requirement as the REST restore endpoint.

Requesting fields back from a mutation ({ id title }) triggers a follow-up fetch by id and is subject to the same field-level read permissions as a query.

Variables, Directives, Fragments, and Aliases

Section titled “Variables, Directives, Fragments, and Aliases”

Standard GraphQL-over-HTTP conveniences are supported:

{
"query": "query($status: String, $withBody: Boolean) { published: posts(status: $status) { id title body @include(if: $withBody) ...meta } } fragment meta on Post { created_at updated_at }",
"variables": { "status": "published", "withBody": true }
}
  • Variables – declared in the operation signature, referenced with $name, passed via the top-level variables object.
  • Directives – @skip(if: Boolean) and @include(if: Boolean) are evaluated per-field.
  • Fragments – both named (...fragmentName + fragment fragmentName on Type { ... }) and inline (... on Type { ... }) are resolved.
  • Aliases – published: posts(...) renames the response key, as shown above.
  • __typename – returns the model name as a string literal wherever selected.

__schema and __type unconditionally raise PermissionDenied("GraphQL introspection is disabled") – this is not configurable per-request. If you need a schema for tooling, generate it from your models.json directly rather than querying /graphql.