Fluent Relations

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools version 6.3, as the official template does; Vapor 4.122.2 itself needs tools version 6.0), with fluent 4.13.0, fluent-kit 1.57.0, fluent-sqlite-driver 4.9.0 and sql-kit 3.36.0, checked on 2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode with zero warnings in a scratch package, and the behaviour described was run: the query counts, SQL statements, error messages and JSON bodies shown are captured from a VaporTesting suite (ten tests, all passing) and from the running service answered with curl, always against in-memory SQLite. PostgreSQL, MySQL and MongoDB expose the same relation API because it is defined in FluentKit above the drivers, but nothing on this page was run against a live server of those; where their behaviour differs (foreign keys in MongoDB, placeholder syntax in the logged SQL) it is said explicitly. Vapor 5 is at 5.0.0-beta.4 and is not released; Fluent has no 5.x release at all (the latest tags are fluent 4.13.0 and fluent-kit 1.57.0), so everything here is the Fluent 4 API. The page extends the Bookshelf models of the earlier pages of this section (the Fluent Models page declares them, the Migrations & Schema page creates the schema, and the Fluent Queries and Pagination page covers filters, joins and pagination in depth); two page-local additions are marked as such. Swift itself is covered by the Swift Reference, key paths by Key Paths and Dynamic Member Lookup, and async functions, Sendable and actors by Async/Await and Tasks and Actors, Isolation and Sendable. The same topic in Rust is in Databases with SQLx, and the relational foundations (keys, one-to-many, many-to-many with a junction table) are in the site’s SQL relations page.

What a relation is in Fluent

A relational database expresses a relationship with a foreign key column. Fluent does not hide that: a relation is a typed way of reading and writing a foreign key and of fetching the rows on the other side of it. There are five property wrappers, and only two of them own a column:

Wrapper Cardinality Stored column Declared on

@Parent(key:)

many-to-one (the "many" side)

<name>_id, required, on this model’s table

the model that holds the foreign key

@OptionalParent(key:)

many-to-one, optional

<name>_id, nullable, on this model’s table

the model that holds the foreign key

@Children(for:)

one-to-many (the "one" side)

none

the model that is referenced; points at the child’s @Parent or @OptionalParent

@OptionalChild(for:)

one-to-one (the side without the column)

none

the model that is referenced; needs a .unique constraint on the child’s foreign key

@Siblings(through:from:to:)

many-to-many

none; the two foreign keys live in the pivot model

both models, each pointing at the pivot

Because @Children, @OptionalChild and @Siblings store nothing, adding them to a model needs no migration; only @Parent, @OptionalParent and the pivot’s two @Parent fields do. FluentKit 1.57.0 also contains composite-key variants (@CompositeParent, @CompositeChildren, @CompositeOptionalChild, @CompositeOptionalParent) for models whose primary key is a group of fields; they are not used by Bookshelf and are not covered further.

Each property wrapper hands you two things, and mixing them up is the commonest beginner error:

  • the wrapped value (book.author, author.books, book.tags) is the related model or array, and it exists only when the relation has been loaded; reading it before then stops the process with a fatalError;

  • the projected value (book.$author, author.$books, book.$tags) is the property object itself, with the foreign key ($author.id), the cached value ($author.value, an optional) and the operations (get, load, query, create, attach, detach, …​).

Where it sits in the request lifecycle

Relations are a data-access feature, so they are used in the middle of the pipeline: after the middleware chain and the router have chosen a handler, and before the response is encoded.

request -> middleware -> router -> handler --(await)--> Fluent query/relations --> database
                                      |
                                      +-> build DTO -> encode (Content) -> response -> middleware -> client

Three places in that line are affected by relations, and the rest of the page keeps returning to them:

  1. In the handler, every get, load, query, create, attach and every eager load is a statement sent to the database and awaited. The number of statements, not the number of lines of Swift, is the cost of a relation access; the N+1 section measures it.

  2. At encoding time, a Model that is Codable encodes its relations too: a loaded @Parent as a nested object, an unloaded one as {"id": ...}, a loaded @Children or @Siblings as an array and an unloaded one not at all. That is rarely the JSON shape you want, so handlers usually map to a DTO (see Encoding relations).

  3. In the database, the foreign keys declared in the migration decide what happens when a row is deleted (see Cascade rules), whatever the Swift code does.

The Bookshelf model

The figure is the entity-relationship diagram of the canonical Bookshelf schema used throughout this section. Crow’s-foot notation: || is exactly one, o{ is zero or many. The BOOK_TAG table is the pivot behind @Siblings.

erDiagram AUTHOR ||--o{ BOOK : "writes (books.author_id)" BOOK ||--o{ REVIEW : "receives (reviews.book_id)" USER ||--o{ REVIEW : "writes (reviews.user_id)" BOOK ||--o{ LOAN : "is lent in (loans.book_id)" USER ||--o{ LOAN : "borrows (loans.user_id)" BOOK ||--o{ BOOK_TAG : "pivot (book_tag.book_id)" TAG ||--o{ BOOK_TAG : "pivot (book_tag.tag_id)" AUTHOR { uuid id PK string name string bio "optional" } BOOK { uuid id PK string title string isbn UK int published_year int price_cents string status "draft, published, archived" uuid author_id FK datetime created_at datetime updated_at } REVIEW { uuid id PK int rating string body uuid book_id FK uuid user_id FK datetime created_at } USER { uuid id PK string email UK string password_hash string role "reader, librarian, admin" } TAG { uuid id PK string name UK } BOOK_TAG { uuid id PK uuid book_id FK uuid tag_id FK } LOAN { uuid id PK uuid book_id FK uuid user_id FK datetime loaned_at datetime due_at datetime returned_at "optional" }

Reading the diagram as Fluent declarations: an Author has @Children books; a Book has an @Parent author, @Children reviews and @Siblings tags; a Review has two @Parent`s (book and user); `Tag has the @Siblings back to books; BookTag is the pivot with two @Parent`s; `Loan has two @Parent`s and an optional timestamp. These are the models of `Models.swift, which are the canonical set shared by every page of this section:

import Fluent
import Vapor

enum BookStatus: String, Codable, Sendable { case draft, published, archived }
enum UserRole: String, Codable, Sendable { case reader, librarian, admin }

final class Author: Model, @unchecked Sendable {
    static let schema = "authors"
    @ID(key: .id) var id: UUID?
    @Field(key: "name") var name: String
    @OptionalField(key: "bio") var bio: String?
    @Children(for: \.$author) var books: [Book]
    init() {}
    init(id: UUID? = nil, name: String, bio: String? = nil) { self.id = id; self.name = name; self.bio = bio }
}

final class Book: Model, @unchecked Sendable {
    static let schema = "books"
    @ID(key: .id) var id: UUID?
    @Field(key: "title") var title: String
    @Field(key: "isbn") var isbn: String
    @Field(key: "published_year") var publishedYear: Int
    @Field(key: "price_cents") var priceCents: Int
    @Enum(key: "status") var status: BookStatus
    @Parent(key: "author_id") var author: Author
    @Children(for: \.$book) var reviews: [Review]
    @Siblings(through: BookTag.self, from: \.$book, to: \.$tag) var tags: [Tag]
    @Timestamp(key: "created_at", on: .create) var createdAt: Date?
    @Timestamp(key: "updated_at", on: .update) var updatedAt: Date?
    init() {}
    init(id: UUID? = nil, title: String, isbn: String, publishedYear: Int, priceCents: Int,
         status: BookStatus = .draft, authorID: Author.IDValue) {
        self.id = id; self.title = title; self.isbn = isbn; self.publishedYear = publishedYear
        self.priceCents = priceCents; self.status = status; self.$author.id = authorID
    }
}

final class User: Model, @unchecked Sendable {
    static let schema = "users"
    @ID(key: .id) var id: UUID?
    @Field(key: "email") var email: String
    @Field(key: "password_hash") var passwordHash: String
    @Enum(key: "role") var role: UserRole
    init() {}
    init(id: UUID? = nil, email: String, passwordHash: String, role: UserRole = .reader) {
        self.id = id; self.email = email; self.passwordHash = passwordHash; self.role = role
    }
}

final class Review: Model, @unchecked Sendable {
    static let schema = "reviews"
    @ID(key: .id) var id: UUID?
    @Field(key: "rating") var rating: Int
    @Field(key: "body") var body: String
    @Parent(key: "book_id") var book: Book
    @Parent(key: "user_id") var user: User
    @Timestamp(key: "created_at", on: .create) var createdAt: Date?
    init() {}
    init(id: UUID? = nil, rating: Int, body: String, bookID: Book.IDValue, userID: User.IDValue) {
        self.id = id; self.rating = rating; self.body = body; self.$book.id = bookID; self.$user.id = userID
    }
}

final class Tag: Model, @unchecked Sendable {
    static let schema = "tags"
    @ID(key: .id) var id: UUID?
    @Field(key: "name") var name: String
    @Siblings(through: BookTag.self, from: \.$tag, to: \.$book) var books: [Book]
    init() {}
    init(id: UUID? = nil, name: String) { self.id = id; self.name = name }
}

final class BookTag: Model, @unchecked Sendable {
    static let schema = "book_tag"
    @ID(key: .id) var id: UUID?
    @Parent(key: "book_id") var book: Book
    @Parent(key: "tag_id") var tag: Tag
    init() {}
    init(bookID: Book.IDValue, tagID: Tag.IDValue) { self.$book.id = bookID; self.$tag.id = tagID }
}

final class Loan: Model, @unchecked Sendable {
    static let schema = "loans"
    @ID(key: .id) var id: UUID?
    @Parent(key: "book_id") var book: Book
    @Parent(key: "user_id") var user: User
    @Field(key: "loaned_at") var loanedAt: Date
    @Field(key: "due_at") var dueAt: Date
    @OptionalField(key: "returned_at") var returnedAt: Date?
    init() {}
    init(id: UUID? = nil, bookID: Book.IDValue, userID: User.IDValue, loanedAt: Date, dueAt: Date) {
        self.id = id; self.$book.id = bookID; self.$user.id = userID; self.loanedAt = loanedAt; self.dueAt = dueAt
    }
}

The foreign keys come from the migration. This is the part of the canonical CreateBookshelf migration that matters for relations (every .references is a foreign key, and every onDelete: .cascade is a rule enforced by the database):

try await db.schema("books").id()
    .field("title", .string, .required)
    // ... isbn, published_year, price_cents, status, created_at, updated_at ...
    .field("author_id", .uuid, .required, .references("authors", "id", onDelete: .cascade))
    .create()

try await db.schema("reviews").id()
    // ... rating, body, created_at ...
    .field("book_id", .uuid, .required, .references("books", "id", onDelete: .cascade))
    .field("user_id", .uuid, .required, .references("users", "id", onDelete: .cascade))
    .create()

try await db.schema("book_tag").id()
    .field("book_id", .uuid, .required, .references("books", "id", onDelete: .cascade))
    .field("tag_id", .uuid, .required, .references("tags", "id", onDelete: .cascade))
    .unique(on: "book_id", "tag_id")
    .create()

Two details deserve a comment. The tables must be created in dependency order (authors and users before books, books before reviews), because a foreign key can only refer to a table that exists; the migration reverts in the opposite order. And the pivot’s .unique(on: "book_id", "tag_id") is what makes "a book has a tag at most once" a database guarantee rather than a hope; without it attach happily inserts duplicates.

Parent and OptionalParent

@Parent(key: "author_id") var author: Author declares a column whose type is the parent’s ID type (here UUID). Internally the wrapper contains a @Field named id (the foreign key, $author.id) and an optional cache ($author.value). You set a parent by writing the ID, never the model:

let book = Book(title: "Notes", isbn: "978-1", publishedYear: 2026, priceCents: 1000, status: .draft,
                authorID: try author.requireID())    // the initializer assigns self.$author.id = authorID
book.$author.id = try otherAuthor.requireID()        // re-parenting later: assign the ID and save()

The initializer is where the assignment belongs: Fluent requires a parameterless init() and gives @Parent no initial value, so the convenience initializer takes the parent’s IDValue and assigns self.$author.id. requireID() throws FluentError.idRequired when the parent has not been saved, which is the right failure: a foreign key cannot point at a row that does not exist yet.

@OptionalParent(key:) is the same with a nullable column and an optional ID ($publisher.id is a UUID?). Use it when the relation may legitimately be absent; the foreign key then usually carries onDelete: .setNull (see Cascade rules). The Bookshelf canon has no optional parent, so this page adds one, Edition.publisher, together with an optional child, in the page-local models below.

Children and OptionalChild

@Children(for: \.$book) var reviews: [Review] is the other end of a @Parent. The argument is a key path to the parent property on the child, \.$book (the $ selects the property object, not its value), which is how Fluent knows which column to filter on. It stores nothing and the declaration needs no migration. What it offers is creation and querying:

  • author.$books.create(book, on: db) and create([books], on: db) set the child’s foreign key to the owner’s ID and save the child (the owner must have been saved);

  • author.$books.query(on: db) returns a normal QueryBuilder<Book> already filtered with books.author_id equal to the owner’s ID, so you add filter, sort, range, count and the rest;

  • get(on:) and load(on:) fetch the array (see Reading related rows).

@OptionalChild(for:) is the one-to-one variant: the owner has zero or one child, and owner.$contact.create(child, on: db) creates it. Fluent cannot enforce "at most one" by itself; the child table needs a .unique constraint on its foreign key, otherwise two children are possible and which one get returns is whichever the database answers first (the Vapor documentation says the same). The wrapper dates from March 2021 (fluent-kit pull request 419, "Add @OptionalChild property wrapper").

The page-local additions below put @OptionalChild, @OptionalParent and the three delete rules into one small model set. A Publisher has at most one PublisherContact (unique foreign key) and many Edition`s, and an `Edition belongs to a Book and optionally to a Publisher. They are not part of the canonical Bookshelf and no other page relies on them.

import Fluent
import Vapor

// Page-local additions: they teach @OptionalParent, @OptionalChild and the other cascade rules.
// The canonical Bookshelf models are not changed.
final class Publisher: Model, @unchecked Sendable {
    static let schema = "publishers"
    @ID(key: .id) var id: UUID?
    @Field(key: "name") var name: String
    @OptionalChild(for: \.$publisher) var contact: PublisherContact?
    @Children(for: \.$publisher) var editions: [Edition]
    init() {}
    init(id: UUID? = nil, name: String) { self.id = id; self.name = name }
}

final class PublisherContact: Model, @unchecked Sendable {
    static let schema = "publisher_contacts"
    @ID(key: .id) var id: UUID?
    @Field(key: "email") var email: String
    @Parent(key: "publisher_id") var publisher: Publisher
    init() {}
    init(id: UUID? = nil, email: String, publisherID: Publisher.IDValue) {
        self.id = id; self.email = email; self.$publisher.id = publisherID
    }
}

final class Edition: Model, @unchecked Sendable {
    static let schema = "editions"
    @ID(key: .id) var id: UUID?
    @Field(key: "label") var label: String
    @Parent(key: "book_id") var book: Book
    @OptionalParent(key: "publisher_id") var publisher: Publisher?
    init() {}
    init(id: UUID? = nil, label: String, bookID: Book.IDValue, publisherID: Publisher.IDValue? = nil) {
        self.id = id; self.label = label; self.$book.id = bookID; self.$publisher.id = publisherID
    }
}

struct CreatePublishers: AsyncMigration {
    func prepare(on db: any Database) async throws {
        try await db.schema("publishers").id().field("name", .string, .required).create()
        try await db.schema("publisher_contacts").id()
            .field("email", .string, .required)
            .field("publisher_id", .uuid, .required, .references("publishers", "id", onDelete: .restrict))
            .unique(on: "publisher_id")
            .create()
        try await db.schema("editions").id()
            .field("label", .string, .required)
            .field("book_id", .uuid, .required, .references("books", "id", onDelete: .cascade))
            .field("publisher_id", .uuid, .references("publishers", "id", onDelete: .setNull))
            .create()
    }
    func revert(on db: any Database) async throws {
        for s in ["editions", "publisher_contacts", "publishers"] { try await db.schema(s).delete() }
    }
}

Observed results from the test suite that exercises these models:

second PublisherContact for the same publisher:
    constraintUniqueFailed: UNIQUE constraint failed: publisher_contacts.publisher_id
Edition.query(on: db).with(\.$publisher).sort(\.$label).all(), mapped to "label=publisher name or none":
    ["First=Acme", "Self-published=none"]
publisher.$contact.get(on: db) before any contact exists:
    nil

Siblings and the pivot

A many-to-many relationship needs a third table. In Fluent it is a pivot model with two @Parent fields, here BookTag (book_tag, columns book_id and tag_id). Each side declares a @Siblings that names the pivot and the two key paths:

// in Book
@Siblings(through: BookTag.self, from: \.$book, to: \.$tag) var tags: [Tag]
// in Tag
@Siblings(through: BookTag.self, from: \.$tag, to: \.$book) var books: [Book]

from is the pivot’s parent that points back at the model declaring the wrapper, to is the other one. The pivot model may carry extra columns (a note, an added-at timestamp, a position); attach takes a closure to fill them. Operations on the projected value, in the async flavour used throughout this section:

Call What it does

book.$tags.attach(tag, on: db)

Inserts one pivot row. A second identical call fails on the unique constraint if the pivot has one (observed: constraintUniqueFailed: UNIQUE constraint failed: book_tag.book_id, book_tag.tag_id).

book.$tags.attach([t1, t2], on: db)

Inserts several pivot rows with one batch create.

book.$tags.attach(tag, method: .ifNotExists, on: db)

Checks first and inserts only if missing: the check is isAttached (observed: one SELECT COUNT(...) filtered by book_id and tag_id), and the insert follows only when it answered false (from the sources). The check and the insert are two statements, so two concurrent requests can both pass the check; the pivot’s unique constraint is the real guard. AttachMethod.always is the plain behaviour. The feature began as attachIfNeeded in February 2020 (fluent-kit pull request 151).

book.$tags.attach(tag, on: db) { pivot in ... }

The trailing closure edits the pivot before it is saved. In the 1.57.0 sources the closure may also be async throws (a 2023 change, pull request 585) and may read the attached model (pull request 565).

book.$tags.isAttached(to: tag, on: db) and isAttached(toID:on:)

One COUNT query; returns a Bool.

book.$tags.detach(tag, on: db) and detach([tags], on: db)

Deletes the matching pivot rows, not the tags.

book.$tags.detachAll(on: db)

Deletes every pivot row of this book.

book.$tags.query(on: db)

A QueryBuilder<Tag> joined through the pivot: the tags of this book, filterable and sortable.

Two properties of @Siblings are easy to miss. attach requires the owner to carry its ID: the call fails with siblings relation Siblings<Book, Tag, BookTag>(from: [book_id], to: [tag_id]) is missing owning model's ID (owner likely unsaved) (a SiblingsPropertyError) for a book that was never saved. And a @Siblings on a model that was created with the batch [Model].create(on:) has no owner ID cached: tag.$books.query(on: db) on such a tag stops the process with Cannot query siblings relation Siblings<Tag, Book, BookTag>(from: [tag_id], to: [book_id]) from unsaved model. (observed while writing this page; the same guard is in the sources of Children and OptionalChild). Single create(on:), save(on:) and rows read from the database do carry the ID. When you batch-create models and then use their relations, re-read them or create them one by one.

The next listing is the service layer of Bookshelf for the relations: publishing a book under an author with its tags, replacing the tag set, attaching idempotently, reading a parent defensively, a relation query, eager loading with the pivots, and a nested eager load for loans. Each function was exercised by the test suite, and the observed results are listed after it.

import Fluent
import Vapor

enum RelationOps {
    /// Children.create sets books.author_id; Siblings.attach inserts the book_tag rows.
    static func publish(title: String, isbn: String, under author: Author, tagged tags: [Tag],
                        on db: any Database) async throws -> Book {
        let book = Book(title: title, isbn: isbn, publishedYear: 2026, priceCents: 2499,
                        status: .published, authorID: try author.requireID())
        try await db.transaction { tx in
            try await author.$books.create(book, on: tx)
            try await book.$tags.attach(tags, on: tx)
        }
        return book
    }

    /// Replaces the whole tag set: detachAll deletes the pivots, attach inserts the new ones.
    static func retag(_ book: Book, with tags: [Tag], on db: any Database) async throws {
        try await db.transaction { tx in
            try await book.$tags.detachAll(on: tx)
            try await book.$tags.attach(tags, on: tx)
        }
    }

    /// Attaches only when the pivot row is missing; returns whether a row was inserted.
    static func ensureTag(_ tag: Tag, on book: Book, db: any Database) async throws -> Bool {
        let before = try await book.$tags.isAttached(to: tag, on: db)
        try await book.$tags.attach(tag, method: .ifNotExists, on: db)
        return !before
    }

    /// Three ways to read a Parent.
    static func authorName(of book: Book, on db: any Database) async throws -> String {
        if let loaded = book.$author.value { return loaded.name }   // already eager loaded or fetched
        let author = try await book.$author.get(on: db)             // query, cache in $author.value, return
        return author.name
    }

    /// Filtering the related rows without loading them: a query on the relation.
    static func expensiveBookCount(of author: Author, above cents: Int, on db: any Database) async throws -> Int {
        try await author.$books.query(on: db).filter(\.$priceCents > cents).count()
    }

    /// An eager load cannot be filtered, so narrow the loaded array in memory; also load the pivots of a Siblings relation.
    static func topReviewed(on db: any Database) async throws -> [(title: String, best: [Int], tagLinks: Int)] {
        let books = try await Book.query(on: db)
            .sort(\.$title)
            .with(\.$reviews)
            .with(\.$tags)
            .with(\.$tags.$pivots)
            .all()
        return books.map { ($0.title, $0.reviews.map(\.rating).filter { $0 >= 4 }.sorted(by: >), $0.$tags.pivots.count) }
    }

    /// Overdue loans with everything the page needs, in four queries.
    static func overdueLoans(asOf now: Date, on db: any Database) async throws -> [Loan] {
        try await Loan.query(on: db)
            .filter(\.$returnedAt == nil)
            .filter(\.$dueAt < now)
            .with(\.$book) { $0.with(\.$author) }
            .with(\.$user)
            .all()
    }
}

Observed results:

publish(... tagged: [databases, fiction])      -> book.$tags.get(on: db) names: ["databases", "fiction"]
retag(book, with: [swift])                     -> names after reload:        ["swift"]
ensureTag(swift) again, then a missing tag     -> false (was attached), then true (inserted)
authorName(of: freshlyFetchedBook)             -> "Author 2" (one query), and $author.value is set afterwards
expensiveBookCount(of: author, above: 2000)    -> 1   (one COUNT query on the relation)
topReviewed                                    -> 4 statements: books, reviews, tags joined with book_tag, book_tag pivots
overdueLoans                                   -> 4 statements: loans, books, authors, users

db.transaction { tx in ... } in publish and retag makes the writes atomic: if attach fails (a duplicate tag), the book created a line earlier is rolled back. Use the transaction handle tx, not the outer db, inside the closure. The closure is @Sendable, which is why the captured models must be Sendable (see Swift 6 and concurrency). Transactions are covered by the Transactions, Model Middleware & Soft Delete page.

Given a book fetched from the database, its relations are not loaded. These are the ways to read them, with different costs:

Access Statements Notes

book.$author.id

0

The foreign key, always present. Enough when you only need to compare, or to build a link.

try await book.$author.get(on: db)

1 on the first call, 0 afterwards

Fetches the parent, stores it in $author.value and returns it. get(reload: true, on: db) refetches (observed: 1 statement; a second plain get costs 0).

try await book.$author.load(on: db)

1

Fetches and stores in $author.value without returning; afterwards book.author is readable. The same pair exists on @Children, @OptionalChild, @OptionalParent and @Siblings.

author.$books.query(on: db) followed by all(), count() and so on

1 per call

A fresh QueryBuilder; nothing is cached on the model. This is the right tool when you need a subset (filter, sort, limit) of the related rows.

Book.query(on: db).with(\.$author).all()

1 per relation for the whole result set

Eager loading; the next section.

book.$author.value is nil until something loaded it, and book.author (no $) is then a fatalError with the message "Parent relation not eager loaded, use $ prefix to access: …​"; @Children and @Siblings have the same trap with their own wording ("Children relation not eager loaded", "Siblings relation not eager loaded", taken from the 1.57.0 sources). The defensive pattern is authorName(of:on:) above: look at $author.value, and get when it is nil.

Setting book.$author.value = someAuthor yourself caches an already-fetched model (useful when you hold the parent from an earlier query), but it does not change the foreign key; assign $author.id for that.

Eager loading

Eager loading asks the query builder to load the relations of all rows it returns, in a fixed number of extra statements:

let books = try await Book.query(on: db)
    .with(\.$author)                                   // 1 extra statement for any number of books
    .with(\.$tags)                                     // 1 extra statement (tags INNER JOIN book_tag ... IN (...))
    .with(\.$reviews) { $0.with(\.$user) }             // reviews, then the users of those reviews: 2 more
    .all()
// books[0].author.name, books[0].tags, books[0].reviews[0].user.email are now plain reads

How it works, from the FluentKit 1.57.0 sources and the captured SQL: the builder runs the main query, collects the distinct key values of the result and issues one WHERE ... IN (...) query per relation; for a @Siblings the query selects from the target table joined with the pivot, so the pivot costs no extra statement. The results are matched to their owners in memory and written into $relation.value. The captured statements further down are the proof: SELECT ... FROM authors WHERE authors.id IN (?1,...,?5) carries the five distinct author IDs of the twenty books.

The rules that follow from that implementation:

  • with takes the key path to the projected property, \.$author, and works on every relation kind, including \.$tags for siblings. The nested form .with(\.$reviews) { $0.with(\.$user) } goes through the relation to the related model’s relations, arbitrarily deep. The closure receives a NestedEagerLoadBuilder, which offers with and nothing else.

  • Eager loads cannot be filtered or sorted. The nested builder has no filter: writing a filter inside the closure does not compile (the compiler reports that NestedEagerLoadBuilder<...> has "no member 'filter'"; observed). Load everything and narrow the array in Swift (topReviewed above keeps the ratings of four and five), run a separate $reviews.query(on:) per owner when the sets are large, or use join when you want the database to filter the owners by a related column.

  • with has overloads taking withDeleted: true, which include soft-deleted related rows; by default they are skipped.

  • Eager loaders run when the result is read as models: all(), first() and paginate. Aggregates such as count() clear them explicitly, and update() and delete() return no models to fill, so with is meaningless there.

  • A model that was not produced by such a query (a freshly built Book(...)) has no loaded relations: eager loading is a property of the query result, not of the type.

  • with(\.$tags.$pivots) additionally loads the pivot rows (as in topReviewed), exposed as book.$tags.pivots; it costs one more statement (SELECT ... FROM book_tag WHERE book_id IN (...), observed).

  • An IN list has as many placeholders as there are distinct keys. Databases limit bind parameters per statement (SQLite’s default build allows 32766, PostgreSQL 65535), so page very large result sets with paginate or range and eager-load per page. Pagination is covered by the Fluent Queries and Pagination page.

The N+1 problem, measured

The "N+1 problem" is the pattern in which code fetches N rows with one query and then runs one more query per row to read a relation: 1 + N statements, each a round trip to the database, when 2 would do. In Fluent it is created by calling get(on:) or load(on:) inside a loop, and it is invisible in the Swift because every get looks harmless. The Patel book (chapter 5, Vapor 2) is where this section’s review of the books flags it: its REST resources serialise related rows one query at a time, and it deletes parents without cascading to their children (the cascade section below shows the Fluent 4 way).

Bookshelf has five handlers that return the same data in different ways. BookListing computes BookRow (title and author name) and BookDetail (title, author, tags and the reviews with their readers):

import Fluent
import Vapor

struct BookRow: Content, Equatable {
    let title: String
    let author: String
}

struct ReviewRow: Content, Equatable {
    let rating: Int
    let reader: String
}

struct BookDetail: Content, Equatable {
    let title: String
    let author: String
    let tags: [String]
    let reviews: [ReviewRow]
}

enum BookListing {
    // N+1: one query for the books, then one more per book.
    static func rowsLazily(on db: any Database) async throws -> [BookRow] {
        let books = try await Book.query(on: db).all()
        var rows: [BookRow] = []
        for book in books {
            let author = try await book.$author.get(on: db)
            rows.append(BookRow(title: book.title, author: author.name))
        }
        return rows
    }

    // Eager loading: two queries however many books there are.
    static func rowsEagerly(on db: any Database) async throws -> [BookRow] {
        let books = try await Book.query(on: db).with(\.$author).all()
        return books.map { BookRow(title: $0.title, author: $0.author.name) }
    }

    // A join: one query, a single result set.
    static func rowsJoined(on db: any Database) async throws -> [BookRow] {
        let books = try await Book.query(on: db).join(Author.self, on: \Book.$author.$id == \Author.$id).all()
        return try books.map { BookRow(title: $0.title, author: try $0.joined(Author.self).name) }
    }

    // Nested N+1: books, then authors, tags and reviews per book, then the reader of every review.
    static func detailsLazily(on db: any Database) async throws -> [BookDetail] {
        let books = try await Book.query(on: db).all()
        var details: [BookDetail] = []
        for book in books {
            let author = try await book.$author.get(on: db)
            let tags = try await book.$tags.get(on: db)
            var reviews: [ReviewRow] = []
            for review in try await book.$reviews.get(on: db) {
                let reader = try await review.$user.get(on: db)
                reviews.append(ReviewRow(rating: review.rating, reader: reader.email))
            }
            details.append(BookDetail(title: book.title, author: author.name, tags: tags.map(\.name), reviews: reviews))
        }
        return details
    }

    // Nested eager loading: five queries in total (books, authors, tags with their pivot rows, reviews, users).
    static func detailsEagerly(on db: any Database) async throws -> [BookDetail] {
        let books = try await Book.query(on: db)
            .with(\.$author)
            .with(\.$tags)
            .with(\.$reviews) { $0.with(\.$user) }
            .all()
        return books.map { book in
            BookDetail(title: book.title, author: book.author.name, tags: book.tags.map(\.name),
                       reviews: book.reviews.map { ReviewRow(rating: $0.rating, reader: $0.user.email) })
        }
    }
}

struct BookListingController: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        let books = routes.grouped("books")
        books.get("simple", "lazy") { req async throws in try await BookListing.rowsLazily(on: req.db) }
        books.get("simple", "eager") { req async throws in try await BookListing.rowsEagerly(on: req.db) }
        books.get("simple", "join") { req async throws in try await BookListing.rowsJoined(on: req.db) }
        books.get("detail", "lazy") { req async throws in try await BookListing.detailsLazily(on: req.db) }
        books.get("detail", "eager") { req async throws in try await BookListing.detailsEagerly(on: req.db) }
    }
}

rowsLazily and detailsLazily are the N+1 versions; rowsEagerly and detailsEagerly load the same relations with with; rowsJoined uses one join, which returns the columns of both tables in a single result set and is read back with joined(Author.self). All three return the same Content DTOs, so the JSON is identical. The detail view reads three relation kinds per book (parent, siblings, children) and a nested parent per review.

Counting statements from the log

Fluent logs every statement through the logger of the database that runs it, at the level given as sqlLogLevel when the database is registered (.debug by default, nil disables it). In configure.swift of the scratch package:

import Fluent
import FluentSQLiteDriver
import Vapor

func configure(_ app: Application) async throws {
    // sqlLogLevel decides the level at which Fluent logs every statement (default .debug).
    app.databases.use(.sqlite(.memory, sqlLogLevel: .info), as: .sqlite)
    app.migrations.add(CreateBookshelf())
    app.migrations.add(CreatePublishers())
    try await app.autoMigrate()
    if app.environment != .testing { try await seedBookshelf(on: app.db) }
    try app.register(collection: BookListingController())
}

With LOG_LEVEL=info the running server prints one Executing query line per statement, carrying the request id, which makes a count per request a one-line awk. Consecutive lines of the lazy handler show the pattern: the author is fetched again for every book, with the same bind, because Fluent does not share loaded models between rows:

[ INFO ] GET /books/simple/lazy [request-id: 4B53E492-...]
[ INFO ] Executing query [binds: [], database-id: sqlite, request-id: 4B53E492-..., sql: SELECT "books"."id" AS "books_id", ... FROM "books"]
[ INFO ] Executing query [binds: [84DDFA22-...], ..., sql: SELECT "authors"."id" AS "authors_id", ... FROM "authors" WHERE "authors"."id" = ?1 LIMIT 1]
[ INFO ] Executing query [binds: [84DDFA22-...], ..., sql: SELECT "authors"."id" AS "authors_id", ... FROM "authors" WHERE "authors"."id" = ?1 LIMIT 1]

The eager handler runs two statements, the second with all five author IDs in one IN list. In an automated test the same information comes from a LogHandler that records the SQL; it is installed on the application’s logger, and every request logger and req.db derives from it:

import Logging
import NIOConcurrencyHelpers

/// Collects the SQL statements Fluent logs at `sqlLogLevel`, so a test can count them.
final class QueryRecorder: Sendable {
    private let lines = NIOLockedValueBox<[String]>([])

    func record(_ line: String) { lines.withLockedValue { $0.append(line) } }
    func reset() { lines.withLockedValue { $0.removeAll() } }
    var statements: [String] { lines.withLockedValue { $0 } }
    var selectCount: Int { statements.filter { $0.hasPrefix("SELECT") }.count }
}

struct RecordingLogHandler: LogHandler {
    let recorder: QueryRecorder
    var metadata: Logger.Metadata = [:]
    var logLevel: Logger.Level = .debug

    subscript(metadataKey key: String) -> Logger.Metadata.Value? {
        get { metadata[key] }
        set { metadata[key] = newValue }
    }

    func log(event: LogEvent) {
        // SQLiteKit logs "Executing query" with the SQL text in the `sql` metadata key.
        guard "\(event.message)" == "Executing query", case .string(let sql)? = event.metadata?["sql"] else { return }
        recorder.record(sql)
    }
}

SQLiteKit logs the statement as the message "Executing query" with the SQL text in the sql metadata key; the handler keeps exactly those events and ignores FluentKit’s own "Running query" line, which would double the count. Other drivers word their messages and metadata differently, so on PostgreSQL or MySQL adapt the filter after looking at one captured event; that was not run on this page.

The tests seed 5 authors, 20 books, 60 reviews, 6 readers and 4 tags and assert the counts. The helper creates a testing Application, swaps its logger and runs the body:

import Fluent
import Foundation
import Testing
import VaporTesting
@testable import App

/// A testing application whose logger feeds a QueryRecorder, with the Bookshelf seed loaded.
func withBookshelf(_ body: (Application, QueryRecorder) async throws -> Void) async throws {
    let recorder = QueryRecorder()
    let app = try await Application.make(.testing)
    app.logger = Logger(label: "bookshelf") { _ in RecordingLogHandler(recorder: recorder) }
    do {
        try await configure(app)
        try await seedBookshelf(on: app.db)
        recorder.reset()
        try await body(app, recorder)
    } catch { try await app.asyncShutdown(); throw error }
    try await app.asyncShutdown()
}

@Suite(.serialized) struct QueryCountTests {
    // The seed holds 20 books, 60 reviews, 6 readers and 4 tags.
    @Test func lazyLoadingIsNPlusOne() async throws {
        try await withBookshelf { app, recorder in
            _ = try await BookListing.rowsLazily(on: app.db)
            let expected = 1 + 20
            #expect(recorder.statements.count == expected)

            recorder.reset()
            _ = try await BookListing.detailsLazily(on: app.db)
            // 1 books + 20 x (author + tags + reviews) + 60 readers
            let expectedNested = 1 + 20 * 3 + 60
            #expect(recorder.statements.count == expectedNested)
        }
    }

    @Test func eagerLoadingIsOneQueryPerRelation() async throws {
        try await withBookshelf { app, recorder in
            _ = try await BookListing.rowsEagerly(on: app.db)
            #expect(recorder.statements.count == 2)

            recorder.reset()
            _ = try await BookListing.detailsEagerly(on: app.db)
            // books, authors, tags (joined with the pivot), reviews, users
            #expect(recorder.statements.count == 5)

            recorder.reset()
            _ = try await BookListing.rowsJoined(on: app.db)
            #expect(recorder.statements.count == 1)
        }
    }

    @Test func bothStrategiesReturnTheSameData() async throws {
        try await withBookshelf { app, _ in
            let lazy = try await BookListing.rowsLazily(on: app.db)
            let eager = try await BookListing.rowsEagerly(on: app.db)
            let joined = try await BookListing.rowsJoined(on: app.db)
            #expect(lazy == eager)
            #expect(eager == joined)
            let lazyDetails = try await BookListing.detailsLazily(on: app.db)
            let eagerDetails = try await BookListing.detailsEagerly(on: app.db)
            #expect(lazyDetails == eagerDetails)
        }
    }

    @Test func endpointsAnswer() async throws {
        try await withBookshelf { app, recorder in
            try await app.testing().test(.GET, "books/detail/eager") { res async in
                #expect(res.status == .ok)
                #expect(recorder.statements.count == 5)
            }
        }
    }
}

The result of the assertions, and of five curl requests against the running server (statements per request counted from the log by request id, so each number is a real count, not an estimate):

Handler Lazy (N+1) Eager with Formula for the lazy version

/books/simple/... (author name of each book)

21 statements

2 statements (join: 1)

1 + N, N = 20 books

/books/detail/... (author, tags, reviews, reader per review)

121 statements

5 statements

1 + 3N + R, R = 60 reviews

For the detail view the eager version needs one statement each for the books, authors, tags (joined with the pivot), reviews and users: five in total however many books there are. Extrapolating with the same formulas, which holds because the lazy code is a plain loop (this is arithmetic, not a measurement): 200 books with three reviews each would cost 1
600 + 600 = 1201 statements lazily and still 5 eagerly. The test bothStrategiesReturnTheSameData asserts that the lazy, eager and joined results are equal, so only the cost differs.

Wall-clock times from the same curl run (a debug build on a laptop, in-memory SQLite, one request each) were 15 ms against 10 ms for the simple list and 115 ms against 20 ms for the detail list. Those figures are nearly meaningless as absolute numbers: with in-memory SQLite a statement costs microseconds. Against a database across a network every statement adds a round trip plus a connection from the pool, so the statement counts above are what predict production behaviour.

Which fix to choose:

  • Eager loading when you want the related models as models and the relations are parent, children or siblings. It costs one statement per relation level and keeps the code readable.

  • A join when you only need a few columns of a to-one relation, or when the filter belongs to the related table (for example, books whose author name matches): one statement, but a to-many join repeats the owner’s columns per related row and cannot fill @Children arrays.

  • An aggregate query (a count of reviews per book) when the page needs numbers, not rows; loading 60 review models only to count them is the same waste in a different shape. Aggregates and raw SQL are covered by the Fluent Queries and Pagination page and the SQLKit and Raw SQL page.

The same waste appears as the same row fetched repeatedly: the lazy listing fetched author 84DDFA22-…​ four times, once per book of that author. Eager loading puts each distinct key in the IN list once, which is why five authors cost one statement for twenty books.

Encoding relations

A Fluent Model is Codable, and the relation wrappers encode themselves. What comes out depends on what has been loaded, and the rules are asymmetric, which surprises people the first time a model is returned straight from a handler:

  • a @Parent encodes as the full parent object when loaded, and as {"id": "..."} when only the foreign key is known;

  • an @OptionalParent encodes as the parent when loaded and as {"id": ...} otherwise, with a null id when the relation is absent (from the 1.57.0 sources; not run);

  • @Children, @OptionalChild and @Siblings encode as an array (or object) when loaded and are omitted when not loaded.

The test below pins that behaviour, and the JSON shown after it is the captured output of the same two cases with the pretty printer turned on:

import Fluent
import Foundation
import Testing
import VaporTesting
@testable import App

@Suite(.serialized) struct EncodingTests {
    private func json(_ book: Book) throws -> [String: Any] {
        let data = try JSONEncoder().encode(book)
        return try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
    }

    @Test func unloadedRelationsEncodeAsTheirKeyOnly() async throws {
        try await withBookshelf { app, _ in
            let book = try #require(try await Book.query(on: app.db).first())
            let object = try json(book)
            let author = try #require(object["author"] as? [String: Any])
            #expect(Set(author.keys) == ["id"])            // only the foreign key
            #expect(object["reviews"] == nil)              // @Children is omitted until loaded
            #expect(object["tags"] == nil)                 // @Siblings too
        }
    }

    @Test func loadedRelationsEncodeNested() async throws {
        try await withBookshelf { app, _ in
            let book = try #require(try await Book.query(on: app.db).with(\.$author).with(\.$tags).first())
            let object = try json(book)
            let author = try #require(object["author"] as? [String: Any])
            #expect(author["name"] as? String == "Author 1")
            let tags = try #require(object["tags"] as? [[String: Any]])
            #expect(tags.count == 2)
        }
    }
}
{
  "author" : { "id" : "9B2A8DB0-188B-415C-995B-8039F5F0F884" },
  "createdAt" : "2026-10-17T15:28:38Z",
  "id" : "70D03509-A1E7-4208-85F2-66271FD39094",
  "isbn" : "978-1",
  "priceCents" : 1000,
  "publishedYear" : 2026,
  "status" : "draft",
  "title" : "Notes",
  "updatedAt" : "2026-10-17T15:28:38Z"
}

That is the book fetched without eager loading. With .with(\.$author).with(\.$tags) the author member becomes the full author ("bio": null, "id": ..., "name": "Ada") and tags appears as an array (empty for this book). The property names are the Swift ones (priceCents), not the column names (price_cents), because the encoder uses the property labels, not the @Field keys.

Returning a model directly therefore leaks the shape of your schema and of your loading strategy to API clients, and a handler that forgets a with changes the response. The robust pattern is the one used by BookRow and BookDetail above: eager load what the DTO needs, map to a Content struct, return that. A model such as User also carries passwordHash, which a direct return would publish; the Fluent Models page and the API Design, Versioning and DTOs page cover DTOs.

Cascade rules

A relation is only as safe as its foreign key (which the Migrations & Schema page shows how to declare and change). .references("authors", "id", onDelete: .cascade) in the migration, not any Swift property, decides what the database does when the referenced row is deleted or its key is updated. onDelete and onUpdate take a DatabaseSchema.ForeignKeyAction:

Rule Effect when the parent row is deleted Observed in the test suite (SQLite)

.noAction (the default)

The delete fails if children exist; the check is made at the end of the statement.

not run separately

.restrict

The delete fails at once if children exist.

constraintTriggerFailed: FOREIGN KEY constraint failed when deleting a Publisher that still has a PublisherContact

.cascade

The children are deleted too, recursively through their own cascade rules.

deleting one author: books 20 to 16, reviews 60 to 48, pivot rows 40 to 32; tags unchanged at 4

.setNull

The children’s foreign key becomes NULL; the column must be nullable (@OptionalParent).

the Edition of a deleted Publisher has $publisher.id == nil

.setDefault

The foreign key takes its column default.

not run; not every database supports it (MySQL’s InnoDB rejects it)

The tests:

@Suite(.serialized) struct DeletionRuleTests {
    @Test func deletingAnAuthorCascades() async throws {
        try await withBookshelf { app, _ in
            let db = app.db
            let author = try #require(try await Author.query(on: db).filter(\.$name == "Author 1").first())
            let before = (books: try await Book.query(on: db).count(), reviews: try await Review.query(on: db).count(),
                          pivots: try await BookTag.query(on: db).count())
            let expectedBefore: [Int] = [20, 60, 40]
            #expect([before.books, before.reviews, before.pivots] == expectedBefore)
            try await author.delete(on: db)
            // authors.id -> books.author_id -> reviews.book_id and book_tag.book_id, all ON DELETE CASCADE
            let after = (books: try await Book.query(on: db).count(), reviews: try await Review.query(on: db).count(),
                         pivots: try await BookTag.query(on: db).count(), tags: try await Tag.query(on: db).count())
            let expectedAfter: [Int] = [16, 48, 32, 4]
            #expect([after.books, after.reviews, after.pivots, after.tags] == expectedAfter)   // the other side of the pivot (tags) survives
        }
    }

    @Test func restrictBlocksAndSetNullClears() async throws {
        try await withBookshelf { app, _ in
            let db = app.db
            let book = try #require(try await Book.query(on: db).first())
            let publisher = Publisher(name: "Acme Press")
            try await publisher.create(on: db)
            try await publisher.$contact.create(PublisherContact(email: "press@example.com", publisherID: publisher.requireID()), on: db)
            try await Edition(label: "First", bookID: book.requireID(), publisherID: publisher.requireID()).create(on: db)

            // publisher_contacts.publisher_id is ON DELETE RESTRICT
            await #expect(throws: (any Error).self) { try await publisher.delete(on: db) }
            let contact = try #require(try await publisher.$contact.get(on: db))
            try await contact.delete(on: db)

            // editions.publisher_id is ON DELETE SET NULL
            try await publisher.delete(on: db)
            let edition = try #require(try await Edition.query(on: db).first())
            #expect(edition.$publisher.id == nil)
            let reloaded = try #require(try await Edition.query(on: db).with(\.$publisher).first())
            #expect(reloaded.publisher == nil)
        }
    }

    @Test func oneToOneIsUniqueOnTheForeignKey() async throws {
        try await withBookshelf { app, _ in
            let publisher = Publisher(name: "Acme Press")
            try await publisher.create(on: app.db)
            try await publisher.$contact.create(PublisherContact(email: "a@example.com", publisherID: publisher.requireID()), on: app.db)
            await #expect(throws: (any Error).self) {
                try await publisher.$contact.create(PublisherContact(email: "b@example.com", publisherID: publisher.requireID()), on: app.db)
            }
        }
    }
}

Points that follow, and mistakes that come from missing them:

  • Fluent does not cascade. author.delete(on: db) sends one DELETE FROM authors WHERE id = ?. Everything else is the database’s work, so the rule must exist in the schema. SQLite only enforces foreign keys when PRAGMA foreign_keys is on; the Fluent SQLite driver turns it on for its connections (the .cascade and .restrict results above are the evidence), but a database opened by another tool may not have it.

  • The default is not what you want. .references("authors", "id") without onDelete is .noAction: deleting an author who has books fails. .cascade is right for owned data (a book’s reviews and tag links); .restrict for data that must not disappear silently (an author with books, when the business rule says so); .setNull for optional links.

  • Cascade multiplies. Deleting one User cascades through reviews and loans; deleting one Author cascades through books, then reviews, book_tag and loans. Soft delete (@Timestamp(on: .delete)) is a Fluent feature that sets a column and fires no database rule, so related rows are not touched; the Transactions, Model Middleware & Soft Delete page covers it.

  • A rule cannot be changed in place. Altering onDelete means dropping the foreign key constraint and creating it again in a schema-changing migration (the Migrations & Schema page).

  • MongoDB has no foreign keys (see the MongoDB page). The relation API works (it filters on the stored ID), but onDelete and uniqueness of the pivot do not exist; deletion of children, if you need it, is application code (preferably in a transaction on a replica set). That was not run here.

  • The pivot’s rule matters as much as the parents'. book_tag is declared with .cascade on both foreign keys, so deleting a tag or a book removes only the link rows, never the other side. Declare .restrict there and a tag could not be deleted while any book uses it.

Swift 6 and concurrency

  • Models are final class ...: Model, @unchecked Sendable. The class holds mutable state (every relation’s cached value, every field), and Fluent mutates it while loading, so the compiler cannot verify that it is safe; @unchecked Sendable is a promise that you do not use one instance from two tasks at once. The db.transaction closure is @Sendable, and capturing a Book in it compiles only because of that promise.

  • get(on:), load(on:) and eager loading write into the model’s property objects. Calling book.$author.get and book.$tags.get in two async let tasks touches two property objects of the same model; it works today, but the compiler cannot see it, and it buys nothing: the two statements share one connection pool. Prefer one eager load.

  • Convert models to Sendable value types (the DTO structs) before they leave the handler or cross to an actor, a background job or a response stream. A Content struct is checked by the compiler; a model is not.

  • Do not fan out get over many models with withThrowingTaskGroup to "speed up" an N+1: it keeps N+1 statements and can exhaust the connection pool. The fix is fewer statements (with, join), not more concurrency.

  • The async API (get, load, attach, detach, create, isAttached) is the one to use. The EventLoopFuture-returning methods in FluentKit are the legacy flavour of the same calls (they bridge to the async ones internally, as the 1.57.0 sources show).

Common errors and pitfalls

Symptom Cause and fix

The process stops with "Parent relation not eager loaded, use $ prefix to access"

book.author was read without a with or get/load. Read book.$author.value, or load first (source message; the Children and Siblings variants say "Children relation not eager loaded" and "Siblings relation not eager loaded").

The process stops with "Cannot query siblings relation …​ from unsaved model."

A relation operation was called on a model that was batch-created with [Model].create(on:) (no cached owner ID) or never saved. Re-read the model, or create it individually. Observed.

siblings relation ... is missing owning model's ID (owner likely unsaved) thrown from attach

The owner has not been saved. create it first. Observed.

constraintUniqueFailed: UNIQUE constraint failed: book_tag.book_id, book_tag.tag_id

The same pair was attached twice and the pivot has a unique constraint (as it should). Use attach(_:method: .ifNotExists, on:), or check isAttached. Observed.

constraintTriggerFailed: FOREIGN KEY constraint failed on delete

A .restrict or .noAction rule blocks the delete, or an insert referenced a parent that does not exist. Observed for .restrict.

FluentError.idRequired from requireID()

The parent was not saved before its ID was used. create it first.

A response with "author": {"id": ...} where the author’s data was expected

The relation was not eager loaded and the model was encoded directly. Eager load and map to a DTO.

One endpoint issues hundreds of statements

A get/load inside a loop. Count statements with sqlLogLevel and with the relation.

Compiler: "value of type 'NestedEagerLoadBuilder<…​>' has no member 'filter'"

Eager loads cannot be constrained. Filter in Swift, query the relation per owner, or join.

Two children for a supposed one-to-one

@OptionalChild without a .unique constraint on the child’s foreign key. Add the constraint in the migration.

.setNull migration fails or the delete does nothing useful

The foreign key column is .required. Make the field optional (@OptionalParent) when the rule is .setNull.

Sibling pairs duplicated

The pivot lacks .unique(on: "book_id", "tag_id").

Running the examples

The scratch package used for this page is the template with Fluent added. The dependency part of its Package.swift (the scratch copy pins exact: for reproducibility; an application normally uses from:):

// swift-tools-version:6.3
dependencies: [
    .package(url: "https://github.com/vapor/vapor.git", from: "4.122.2"),
    .package(url: "https://github.com/vapor/fluent.git", from: "4.13.0"),
    .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.9.0"),
],
targets: [
    .executableTarget(name: "App", dependencies: [
        .product(name: "Vapor", package: "vapor"),
        .product(name: "Fluent", package: "fluent"),
        .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
    ]),
    .testTarget(name: "AppTests", dependencies: [
        .target(name: "App"),
        .product(name: "VaporTesting", package: "vapor"),
    ]),
]

Run the suite, then the server with SQL logging on, and count statements per request:

swift test                                   # all tests, in-memory SQLite

LOG_LEVEL=info swift run App serve --hostname 127.0.0.1 --port 8089 > serve.log 2>&1 &
for p in simple/lazy simple/eager simple/join detail/lazy detail/eager; do
  curl -s -o /dev/null -w "$p %{http_code}\n" http://127.0.0.1:8089/books/$p
done
grep -E '^\[ INFO \] (GET|Executing)' serve.log \
  | awk '/\] GET /{if(n!="")print n, c; n=$5; c=0; next} /Executing query/{c++} END{print n, c}'
simple/lazy 200
simple/eager 200
simple/join 200
detail/lazy 200
detail/eager 200
/books/simple/lazy 21
/books/simple/eager 2
/books/simple/join 1
/books/detail/lazy 121
/books/detail/eager 5

The server’s start-up statements (migrations and seeding) are logged before the first request and are not counted. swift test ran ten tests in four suites (query counts, deletion rules, encoding and the relation operations), all passing.

Version notes

  • Fluent 3 to 4. Vapor 3 used generic relation types and accessors such as parent, children and siblings on the model and futures everywhere. Fluent 4 (Vapor 4.0, 2020) introduced the property wrappers, the projected $ value, key-path based with and join, and query(on: req.db).

  • @OptionalChild is from March 2021; attachIfNeeded/isAttached are from February 2020 (the present spelling is attach(_:method: .ifNotExists, on:)); the pivot-editing closure became able to read the attached model and to be async throws in 2023. The composite-key relation wrappers exist in 1.57.0.

  • Async API. The relation methods have async throws overloads (get, load, create, attach, detach, detachAll, isAttached); the future-returning versions remain.

  • Vapor 5. The latest Vapor 5 is 5.0.0-beta.4 and Fluent has no 5.x release (fluent 4.13.0 and fluent-kit 1.57.0 are the newest tags; a fluent-5 branch exists in the fluent-kit repository). Expect the relation API to be source-compatible in spirit, but treat any claim about Fluent on Vapor 5 as unverified until a release exists.

Where the books are outdated

  • Condon et al. (Server-Side Swift with Vapor, Kodeco) teaches @Parent, @Children (chapter 9) and @Siblings with a pivot, attach, detach and cascade deletes (chapter 10) on the Vapor 4.0 API, with EventLoopFuture chains (flatMap, map, transform) where this page uses async/await. The relation model is unchanged; the signatures are async throws now, the pivot closure can be async, and .ifNotExists is the idempotent attach. Chapter 30 (nested and eager loading, joins, soft delete) belongs to the part of the book that the early-access edition still has on Vapor 3 and Swift 4.2, so its code must be rewritten with with, join and the @Timestamp(on: .delete) soft delete rather than copied.

  • Patel (Hands-On Full-Stack Development with Swift, Packt) is Vapor 2 with Fluent 2: relations are Model methods, there are no property wrappers, and chapter 5 loads related rows one at a time while serialising (the N+1 pattern measured above) and deletes parents without cascading to children. In Fluent 4 use with and onDelete: .cascade.

  • All three books predate the habit of counting statements. None of them shows sqlLogLevel or tests that assert a query budget; the recorder and the assertions above are the replacement.