Last Updated: 3/20/2026
Relations (JSON Nesting)
Kysely is not an ORM and doesn’t have the concept of relations. However, you can fetch related rows in a single query using JSON aggregation helpers. These helpers use database-specific JSON functions to nest related data as JSON arrays or objects.
The Helpers
Kysely provides dialect-specific helpers in src/helpers/:
jsonArrayFrom()— Aggregates a subquery into a JSON array.jsonObjectFrom()— Turns a subquery into a JSON object (expects one row).jsonBuildObject()— Builds a JSON object from expressions.
Each dialect has its own implementation because the underlying SQL functions differ:
- PostgreSQL uses
json_agg()andto_json() - MySQL uses
json_arrayagg()andjson_object() - SQLite uses
json_group_array()andjson_object()
Fetching Arrays of Related Rows
Use jsonArrayFrom() to fetch related rows as a JSON array:
import { jsonArrayFrom } from 'kysely/helpers/postgres'
const result = await db
.selectFrom('person')
.select((eb) => [
'id',
'first_name',
jsonArrayFrom(
eb.selectFrom('pet')
.select(['pet.id as pet_id', 'pet.name'])
.whereRef('pet.owner_id', '=', 'person.id')
.orderBy('pet.name')
).as('pets')
])
.execute()
console.log(result[0]?.pets[0]?.pet_id)
console.log(result[0]?.pets[0]?.name)The generated SQL (PostgreSQL):
select "id", "first_name", (
select coalesce(json_agg(agg), '[]') from (
select "pet"."id" as "pet_id", "pet"."name"
from "pet"
where "pet"."owner_id" = "person"."id"
order by "pet"."name"
) as agg
) as "pets"
from "person"The result type is fully inferred:
// result[0] has type:
{
id: number
first_name: string
pets: Array<{
pet_id: number
name: string
}>
}Fetching a Single Related Row
Use jsonObjectFrom() to fetch a single related row as a JSON object:
import { jsonObjectFrom } from 'kysely/helpers/postgres'
const result = await db
.selectFrom('person')
.select((eb) => [
'id',
'first_name',
jsonObjectFrom(
eb.selectFrom('pet')
.select(['pet.id as pet_id', 'pet.name'])
.whereRef('pet.owner_id', '=', 'person.id')
.where('pet.is_favorite', '=', true)
).as('favorite_pet')
])
.execute()
console.log(result[0]?.favorite_pet?.pet_id)
console.log(result[0]?.favorite_pet?.name)The generated SQL (PostgreSQL):
select "id", "first_name", (
select to_json(obj) from (
select "pet"."id" as "pet_id", "pet"."name"
from "pet"
where "pet"."owner_id" = "person"."id"
and "pet"."is_favorite" = $1
) as obj
) as "favorite_pet"
from "person"The result type includes null because the subquery might not return a row:
// result[0] has type:
{
id: number
first_name: string
favorite_pet: {
pet_id: number
name: string
} | null
}Dialect Differences
The helpers work the same way across dialects, but the generated SQL differs:
PostgreSQL
import { jsonArrayFrom } from 'kysely/helpers/postgres'Uses json_agg() and to_json(). Automatically parses JSON into JavaScript objects.
MySQL
import { jsonArrayFrom } from 'kysely/helpers/mysql'Uses json_arrayagg() and json_object(). Automatically parses JSON into JavaScript objects with the built-in MysqlDialect.
Note: MySQL’s json_object() function doesn’t support selectAll(). You must explicitly select columns:
// ❌ This will throw an error
jsonArrayFrom(
eb.selectFrom('pet').selectAll().whereRef('pet.owner_id', '=', 'person.id')
)
// ✅ This works
jsonArrayFrom(
eb.selectFrom('pet')
.select(['pet.id', 'pet.name'])
.whereRef('pet.owner_id', '=', 'person.id')
)SQLite
import { jsonArrayFrom } from 'kysely/helpers/sqlite'Uses json_group_array() and json_object(). Automatically parses JSON into JavaScript objects with the built-in SqliteDialect.
ParseJSONResultsPlugin
Some third-party dialects may return JSON columns as strings instead of parsing them automatically. In these cases, use the ParseJSONResultsPlugin (see src/plugin/parse-json-results/parse-json-results-plugin.ts):
import { ParseJSONResultsPlugin } from 'kysely'
const db = new Kysely<Database>({
dialect: myDialect,
plugins: [new ParseJSONResultsPlugin()]
})This plugin walks the result rows and parses any string that looks like JSON.
The $notNull() Helper
When you know a jsonObjectFrom() result will never be null, use the $notNull() helper to refine the type:
import { jsonObjectFrom } from 'kysely/helpers/postgres'
const result = await db
.selectFrom('person')
.select((eb) => [
'id',
jsonObjectFrom(
eb.selectFrom('pet')
.select(['pet.id as pet_id', 'pet.name'])
.whereRef('pet.owner_id', '=', 'person.id')
.limit(1)
).$notNull().as('first_pet')
])
.execute()
// result[0].first_pet is typed as { pet_id: number; name: string }
// (not null)Warning: This is a type-level assertion. If the subquery actually returns null, you’ll get a runtime error.
Building JSON Objects from Expressions
Use jsonBuildObject() to build a JSON object from individual expressions:
import { sql } from 'kysely'
import { jsonBuildObject } from 'kysely/helpers/postgres'
const result = await db
.selectFrom('person')
.select((eb) => [
'id',
jsonBuildObject({
first: eb.ref('first_name'),
last: eb.ref('last_name'),
full: sql<string>`first_name || ' ' || last_name`
}).as('name')
])
.execute()
console.log(result[0]?.name.first)
console.log(result[0]?.name.last)
console.log(result[0]?.name.full)The generated SQL (PostgreSQL):
select "id", json_build_object(
'first', first_name,
'last', last_name,
'full', first_name || ' ' || last_name
) as "name"
from "person"Nested Relations
You can nest jsonArrayFrom() and jsonObjectFrom() to fetch deeply nested data:
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'
const result = await db
.selectFrom('person')
.select((eb) => [
'id',
'first_name',
jsonArrayFrom(
eb.selectFrom('pet')
.select((eb) => [
'pet.id',
'pet.name',
jsonObjectFrom(
eb.selectFrom('toy')
.select(['toy.id as toy_id', 'toy.name as toy_name'])
.whereRef('toy.pet_id', '=', 'pet.id')
.limit(1)
).as('favorite_toy')
])
.whereRef('pet.owner_id', '=', 'person.id')
).as('pets')
])
.execute()
console.log(result[0]?.pets[0]?.favorite_toy?.toy_name)The generated SQL (PostgreSQL):
select "id", "first_name", (
select coalesce(json_agg(agg), '[]') from (
select
"pet"."id",
"pet"."name",
(
select to_json(obj) from (
select "toy"."id" as "toy_id", "toy"."name" as "toy_name"
from "toy"
where "toy"."pet_id" = "pet"."id"
limit $1
) as obj
) as "favorite_toy"
from "pet"
where "pet"."owner_id" = "person"."id"
) as agg
) as "pets"
from "person"Performance Considerations
JSON aggregation happens in the database, so you fetch all related data in a single query. This is often faster than N+1 queries, but can be slower than separate queries for large datasets.
Pros:
- Single roundtrip to the database
- Atomic snapshot of data
- No N+1 query problem
Cons:
- Can produce large result sets
- May be slower than separate queries for large datasets
- Limited control over pagination of nested data
For large datasets, consider using separate queries with pagination or a dedicated GraphQL/REST API layer.
What’s Next
- Expressions — Learn how to build complex expressions for filtering related rows.
- Plugins — Use
ParseJSONResultsPluginwhen your dialect doesn’t parse JSON automatically.