Fluent Models

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, FluentKit 1.57.0 and the SQLite driver 4.9.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 against an in-memory SQLite database from a VaporTesting suite; the output shown is captured from those runs. The PostgreSQL, MySQL and MongoDB drivers were part of the same package and compiled, but nothing on this page was run against a live server of those. Vapor 5 is at 5.0.0-beta.4 and is not released yet; at the time of writing the fluent repository still declares a dependency on Vapor 4.117 or newer and no Fluent release specific to Vapor 5 was found, so the models here are described as they are in Vapor 4.

The page defines the vocabulary the rest of the persistence pages use: the Bookshelf models (Author, Book, Review, User, Tag, BookTag and Loan), what each property wrapper stores, and how a model is created, read, changed and deleted. Relations between models are the topic of the Fluent Relations page, schemas and migrations the topic of the Migrations & Schema page, and queries the topic of the Fluent Queries and Pagination page; this page touches them only where a model cannot be explained without them. Swift itself is covered by the Swift Reference, and Sendable and actors by Actors, Isolation and Sendable. The same topic in Rust is in Databases with SQLx, which maps rows with plain structs instead of a property-wrapper ORM.

What a Fluent model is

A Fluent model is a Swift class that describes one row of a table (or one document of a MongoDB collection) and, at the same time, one instance of that row in memory. It has three jobs:

  • it names the table (schema) and each column (the key of every property wrapper);

  • it holds the values of one row while your code works with it, including the state Fluent needs to decide between INSERT and UPDATE (the exists flag of the identifier and the original values of every field);

  • it is the type that queries return, so Book.query(on: db).all() is an [Book].

A model does not describe the column types, the constraints, the indexes or the foreign keys. Those belong to a migration, which the Migrations & Schema page covers. The model and the migration are two descriptions of the same table that the compiler cannot check against each other: if the keys differ, the mismatch appears at run time, as the errors at the end of this page show.

Where it sits in the request lifecycle

Models live in the handler part of the lifecycle. A request passes the middleware chain, the router matches a route and the handler runs; the handler decodes a DTO from the body, loads or builds a model, calls create, update or delete on it with req.db and encodes a DTO back. Nothing in the middleware or in the router knows about Fluent, except middleware that you write yourself and that queries the database (an authenticator, for example).

sequenceDiagram participant C as Client participant M as Middleware participant R as Router participant H as Handler participant F as Fluent model participant D as Database C->>M: PATCH /books/42 M->>R: request R->>H: matched route H->>H: decode UpdateBook (DTO) H->>F: Book.find(id, on: req.db) F->>D: SELECT D-->>F: row F-->>H: Book instance (a class) H->>F: book.price = ..., book.update(on: req.db) F->>D: UPDATE changed columns H-->>C: BookDTO (encoded response)

The two facts that follow from the picture are used throughout the page. A model instance belongs to the handler that loaded it: it is mutable, it is not copied, and the next request gets its own. And every database call on it is an await point that needs a Database value: req.db inside a handler, app.db outside one.

Anatomy of a model

Here is Book, the central model of Bookshelf, exactly as it is declared in the scratch package. The canonical set of models is the one used by the other persistence pages; the identifiers, the keys and the column names are the same everywhere.

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
    }
}

Reading it from the top:

  • final class Book: Model, @unchecked Sendable - a model must be a class, because Fluent mutates the property wrapper instances in place, and final because the wrappers are initialised in the class body. The @unchecked Sendable is explained in the last section of the page.

  • static let schema - the table name. It is the only static requirement of Model. A second, optional static let space: String? names the database schema (PostgreSQL) or database (MySQL) that contains the table; leave it nil for the default.

  • @ID - the primary key, a UUID? here.

  • @Field, @Enum, @Timestamp - columns, each with an explicit database key.

  • @Parent, @Children, @Siblings - relations. They are properties of the model but not columns of its table, except @Parent, whose author_id column is a real field. The Fluent Relations page covers them.

  • init() - required, see below.

  • a second init with the parameters you care about - convenience for your own code.

The key is the contract, the property name is not

Every wrapper takes the database key as a string, and the key is a snake_case column name (published_year) while the Swift property is camelCase (publishedYear). The two never have to match, and renaming a property is free; renaming a key is a schema change that needs a migration. The key is used by the driver to build SQL, by Codable only for the database side (the JSON side uses the property names, see the DTO section) and by the filter key paths (Book.query(on: db).filter(\.$title == "Dune") compiles to WHERE "books"."title" = ?).

The init() requirement

Model inherits from Fields, and Fields requires a parameterless init(). Fluent builds an empty instance with it for every row it decodes, then fills the property wrappers from the database output. Two consequences:

  • A class with only custom initialisers does not conform. Swift suppresses the synthesised init() as soon as you write any initialiser, so the compiler reports (captured with a one-property model whose only initialiser took a name):

    error: type 'OnlyDesignated' does not conform to protocol 'Fields'
    note: protocol requires initializer 'init()' with type '()'

    The fix is the empty init() {} shown in every model of this page.

  • A class with no initialiser at all compiles, because all properties are property wrappers with a default initialisation and Swift synthesises init(). Writing init() {} explicitly anyway keeps the model compiling when you add a custom initialiser later.

Do not put logic in init(). It runs once per row on every query, before any value is loaded, and the wrappers hold no values yet.

Reading a field that was never loaded

Property wrappers have no values until Fluent fills them in. Reading a non-optional @Field (or an @Enum, a @Timestamp that is not optional) before it was set or fetched is not a thrown error but a fatalError:

Fatal error: Cannot access field before it is initialized or fetched: title

It happens when a query selected only some columns (.field(\.$title)), when you build a model with Book() and read before assigning, and when you read a relation that was not loaded. Reading an @OptionalField that was not loaded gives nil rather than trapping, which hides the same mistake in a quieter way.

Identifiers: @ID

@ID declares the primary key. Every model has exactly one, and it is optional (UUID?) because a new model has no identifier until it is saved.

@ID var id: UUID?                 // the default: key "id", generated by Fluent
@ID(key: .id) var id: UUID?       // older spelling, still compiles

The no-argument form is the one the current template uses. In FluentKit 1.57 @ID(key: .id) is kept as a compatibility spelling and is documented as no longer recommended; any other string in that position (@ID(key: "foo")) traps with "@ID() may not specify a key; use @ID(custom:) for 'foo'".

Three kinds of identifier

@ID(custom:generatedBy:) chooses a different key and a different type. The generatedBy argument says who produces the value:

generatedBy Meaning Default for

.random

Fluent calls generateRandom() on the type when it creates the row. The type must conform to RandomGeneratable.

UUID (and @ID with no arguments)

.database

Fluent sends DEFAULT and reads the value back after the insert. Needs an auto-incrementing column.

Int

.user

Your code sets the value before saving; Fluent does not generate one.

every other type, such as String

Two page-local additions show the other two kinds. They are not part of the canonical Bookshelf set and are used only to teach the identifier rules (their migration is in the next listing):

  • Publisher has an Int key that the database generates, so id is nil until the insert has run and Publisher.find(1, on: db) works with an integer.

  • Edition has a String key, an ISBN, that the application supplies. generatedBy: .user is the default for String; it is written out for clarity.

The identifier kinds interact with the databases. Auto-increment (.database) is an SQL concept, and the MongoDB driver requires the identifier to be a UUID-or-ObjectID shaped key named id, so custom identifiers are an SQL feature; the official page says plainly that custom identifiers are not compatible with NoSQL drivers.

exists and requireID

Fluent decides between INSERT and UPDATE with one boolean, $id.exists. It is false for an instance you built with an initialiser, true for an instance a query returned, and Fluent flips it when create succeeds (to true) and when delete(force: true) succeeds (back to false).

let book = Book(title: "Dune", isbn: "978-0-441-17271-9", publishedYear: 1965,
                priceCents: 999, authorID: authorID)
book.$id.exists        // false: save() will INSERT
try await book.save(on: db)
book.$id.exists        // true:  the next save() will UPDATE

Setting id yourself on a new model does not make exists true. Building Book(id: someUUID, ...) and calling save runs an INSERT with that identifier, which is how you create a row with an identifier you chose, and it fails on a duplicate. Conversely, a model decoded from JSON (a client sent "id": "...") has exists == false and a save on it inserts a new row rather than updating the old one. Load the row first and copy the changes onto it, as the update route later in the page does.

requireID() is the one-line way to turn the optional identifier into a value:

let id = try book.requireID()     // throws FluentError.idRequired when id is nil

It throws FluentError.idRequired, whose description is ID required. Use it for an identifier that must exist at that point, such as the authorID argument of a related model’s initialiser, and prefer it over book.id!.

Columns: @Field and @OptionalField

@Field(key:) stores any Codable value, and @OptionalField(key:) stores an optional one:

@Field(key: "title") var title: String
@OptionalField(key: "bio") var bio: String?     // Author.bio in the scratch package

What the property wrappers do is small and worth knowing:

  • The value type must be Codable (and Sendable in Fluent 4.13). Passing a type that is not Codable produces "generic type alias 'Field' requires that 'Opaque' conform to 'Decodable'" (and one more for Encodable).

  • Simple types (String, Int, Bool, Double, Date, UUID, Data) map to native column types. Anything else ([String], a struct, a dictionary) is stored through its Codable representation: a JSON column on drivers that have one, and a document on MongoDB. You cannot filter on the inside of such a value in a portable way, so prefer a group (below) or a relation for data you query on.

  • A @Field of an Optional type compiles but is not the same as @OptionalField. Use @OptionalField for a nullable column, as the official page does.

  • Do not add willSet or didSet observers to a non-optional field; the official page warns that this causes a fatal error because the wrapper is read before it is initialised.

@Field is also how to store an enum without a database enum type. A String- or Int-backed Codable enum in a plain @Field is stored as its raw value in an ordinary string or integer column; the difference to @Enum is the column type the migration declares and what the database enforces.

Enums: @Enum and @OptionalEnum

@Enum(key:) stores a String-backed enum as a native database enum (a PostgreSQL ENUM type, a MySQL ENUM column; SQLite has none and stores text). BookStatus and UserRole in the Bookshelf models are the example:

enum BookStatus: String, Codable, Sendable { case draft, published, archived }

@Enum(key: "status") var status: BookStatus

The enum must be String-backed and Codable, and the migration must create the enum type before it creates the column, with database.enum("book_status").case("draft").case("published").case("archived").create() and .field("status", status, .required), as the canonical migration in this section does. The Migrations & Schema page covers adding a case later. @OptionalEnum is the same for a nullable column (the kind of the page-local Shelf below).

The trade-off between @Enum and an enum in a @Field: a database enum rejects an unknown value at the database, which is the safer default, but changing the set of cases is a migration (in PostgreSQL adding a value is easy and removing one is not). A @Field holding the raw value needs no enum migration, accepts any string in the column, and fails when decoding a row with a value the enum does not know. Decoding an unknown value in either variant throws at query time, not at compile time.

Timestamps

@Timestamp(key:on:format:) is a date column that Fluent maintains for you. The on: argument is a trigger:

Trigger When Fluent sets the value

.create

when the row is inserted

.update

when the row is inserted and every time update or save changes it

.delete

when delete runs without force: true; this turns on soft delete, described below

The property must be Date?, because it is nil until the trigger fires:

@Timestamp(key: "created_at", on: .create) var createdAt: Date?
@Timestamp(key: "updated_at", on: .update) var updatedAt: Date?

In the scratch package a new Book has createdAt == nil, and after save it holds the date (the output is in the CRUD walk below). Never assign a timestamp whose trigger you rely on: Fluent overwrites it on the next trigger.

Timestamps are set by the Fluent process, with its clock, not by the database. That matters when several servers share a database and their clocks differ, and it means a bulk UPDATE through the query builder (the Fluent Queries and Pagination page) does not set updated_at unless you set it.

Storage formats

The optional format: chooses the stored representation:

Format Stored as Migration field type

.default (omitted)

the driver’s native date-time type

.datetime

.iso8601 (optionally with milliseconds)

an ISO 8601 string

.string

.unix

seconds since 1970 including a fraction, a Double

.double

The page-local Publisher uses .iso8601. The raw row read back from SQLite proves the column holds the string, not a number:

registered_at: "2026-10-17T15:27:51Z"

Using format: .iso8601 with a .datetime column (or the reverse) fails at run time, not at compile time. The migration is what has to agree.

Soft delete

A @Timestamp with on: .delete turns on soft delete for the model. Calling delete(on:) then sets the timestamp instead of removing the row; every query hides rows whose delete timestamp is in the past; and three methods remain for the deleted rows:

try await shelf.delete(on: db)                 // soft: sets deleted_at
try await shelf.restore(on: db)                // clears deleted_at
try await shelf.delete(force: true, on: db)    // really removes the row
let all = try await Shelf.query(on: db).withDeleted().all()   // includes soft-deleted rows

The page-local Shelf model (below) does exactly that, and the captured behaviour is: after a soft delete the instance still has $id.exists == true and deletedAt is set; Shelf.query(on: db).count() is 0 while .withDeleted().count() is 1; Shelf.find(id, on: db) returns nil; after restore the count is 1 again; after delete(force: true) the row is gone and exists is false.

Because the delete date may be in the future, a soft-delete timestamp doubles as an expiry date: a row stops appearing in queries when the date passes. Two cautions: a unique constraint still sees the soft-deleted row, so a soft-deleted book keeps its ISBN, and restore on a model without a delete timestamp is a fatalError("no delete timestamp on this model") inside Fluent rather than a thrown error. The Transactions, Model Middleware & Soft Delete page goes deeper into soft delete and its interaction with relations.

@Group and @Boolean

@Group(key:) embeds a set of fields defined by a Fields type. Unlike a Codable struct in a @Field, the grouped fields are separate columns and stay filterable. Columns are named by joining the group key and the field key with an underscore, so a group location with a field floor is the column location_floor:

final class Location: Fields, @unchecked Sendable {
    @Field(key: "floor") var floor: Int
    @OptionalField(key: "room") var room: String?
    init() {}
}

@Group(key: "location") var location: Location
// Shelf.query(on: db).filter(\.$location.$floor == 2)   // WHERE location_floor = 2

@Boolean(key:format:) stores a Bool. The default format is the driver’s native boolean (.bool in the migration). The other formats exist for databases that were designed before Fluent: .integer, .oneZero (the strings "1" and "0"), .yn, .yesNo, .onOff and .trueFalse. They are not for new schemas; they let a model map an existing column that holds "Y" and "N" to a Swift Bool. A plain @Field(key:) var x: Bool also works and is what a new schema should use, so @Boolean is only needed for the legacy formats.

The page-local models

The next listing contains every page-local model used on this page and the migration that creates their tables. Shelf has a group, a boolean, an optional enum and a soft-delete timestamp; Publisher has an Int key and an ISO 8601 timestamp; Edition has a user-supplied String key. They are page-local additions, they are not renamed versions of the canonical models, and they are not used by the other pages of this section.

import Fluent
import Vapor

enum ShelfKind: String, Codable, Sendable { case fiction, reference, children }

final class Location: Fields, @unchecked Sendable {
    @Field(key: "floor") var floor: Int
    @OptionalField(key: "room") var room: String?
    init() {}
    init(floor: Int, room: String? = nil) { self.floor = floor; self.room = room }
}

/// Page-local model: teaches @Group, @Boolean, @OptionalEnum and soft delete.
final class Shelf: Model, @unchecked Sendable {
    static let schema = "shelves"

    @ID var id: UUID?
    @Field(key: "label") var label: String
    @Group(key: "location") var location: Location
    @Boolean(key: "is_public") var isPublic: Bool
    @OptionalEnum(key: "kind") var kind: ShelfKind?
    @Timestamp(key: "deleted_at", on: .delete) var deletedAt: Date?

    init() {}

    init(id: UUID? = nil, label: String, location: Location, isPublic: Bool = true, kind: ShelfKind? = nil) {
        self.id = id
        self.label = label
        self.location = location
        self.isPublic = isPublic
        self.kind = kind
    }
}

/// Page-local model: an Int primary key generated by the database.
final class Publisher: Model, @unchecked Sendable {
    static let schema = "publishers"

    @ID(custom: "id") var id: Int?
    @Field(key: "name") var name: String
    @Timestamp(key: "registered_at", on: .create, format: .iso8601) var registeredAt: Date?

    init() {}
    init(name: String) { self.name = name }
}

/// Page-local model: a String primary key that the application supplies.
final class Edition: Model, @unchecked Sendable {
    static let schema = "editions"

    @ID(custom: "isbn", generatedBy: .user) var id: String?
    @Field(key: "printing") var printing: Int

    init() {}
    init(isbn: String, printing: Int) { self.id = isbn; self.printing = printing }
}

struct CreateLocalModels: AsyncMigration {
    func prepare(on db: any Database) async throws {
        let kind = try await db.enum("shelf_kind").case("fiction").case("reference").case("children").create()
        try await db.schema("shelves")
            .id()
            .field("label", .string, .required)
            .field("location_floor", .int, .required)
            .field("location_room", .string)
            .field("is_public", .bool, .required)
            .field("kind", kind)
            .field("deleted_at", .datetime)
            .create()
        try await db.schema("publishers")
            .field("id", .int, .identifier(auto: true))
            .field("name", .string, .required)
            .field("registered_at", .string)
            .create()
        try await db.schema("editions")
            .field("isbn", .string, .identifier(auto: false))
            .field("printing", .int, .required)
            .create()
    }

    func revert(on db: any Database) async throws {
        try await db.schema("editions").delete()
        try await db.schema("publishers").delete()
        try await db.schema("shelves").delete()
        try await db.enum("shelf_kind").delete()
    }
}

What ran against it: a Shelf saved with floor 2, room "East", isPublic == false and kind fiction produced this SQLite row (captured with a raw SELECT *):

id: "5F6477A3-8F4D-4FBE-B311-3FC4CA596A19", label: "F-12", location_floor: 2, location_room: "East",
is_public: 0, kind: "fiction", deleted_at: null

and encoding the loaded model with a plain JSONEncoder gives the group as a nested object (the key order of a JSON object is not significant):

{"kind":"fiction","isPublic":false,"label":"F-12","id":"5F6477A3-8F4D-4FBE-B311-3FC4CA596A19","deletedAt":null,"location":{"room":"East","floor":2}}

The life of a model: CRUD

The four writes and two reads on a model are methods that take a Database. All of them have async throws versions in FluentKit 1.57; the EventLoopFuture versions are still there, delegate to the async ones and are legacy.

Method Behaviour

create(on:)

INSERT. Generates the identifier (for .random), sets created_at and updated_at, then sets exists. Crashes with a precondition failure if the model already exists.

update(on:)

UPDATE of the changed columns only. Does nothing, and sends no query, when there are no changes (hasChanges == false). Crashes with a precondition failure if the model does not exist.

save(on:)

update when $id.exists, otherwise create. The right default for application code.

delete(force:on:)

DELETE by identifier (or a soft delete, see above). Throws FluentError.idRequired when the model has no identifier.

restore(on:)

undoes a soft delete.

Model.find(_:on:)

the row with that identifier, or nil. The identifier argument is optional, so find(req.parameters.get("bookID"), on: db) needs no unwrapping and returns nil for a missing or malformed parameter.

requireID()

the identifier or FluentError.idRequired.

Two precondition failures deserve a warning because they take the process down rather than throw: calling create on a model that already exists (Precondition failed at Model+Concurrency.swift, captured when an instance was created twice in a test), and calling update on a model that was never saved. save avoids both. The cost of save is the one hidden decision: a model you built by hand or decoded from JSON always has exists == false.

A complete walk

The next function runs one book through the whole cycle and logs what the model reports at each step. It is called from a test with an in-memory SQLite database; there is nothing special about running it inside a handler, where the db is req.db.

import Fluent
import Vapor

/// Walks one Book through its whole life and prints what Fluent did to the model.
func crudWalk(on db: any Database, logger: Logger) async throws {
    let author = Author(name: "Ursula K. Le Guin")
    logger.info("new author: id=\(String(describing: author.id)) exists=\(author.$id.exists)")
    try await author.create(on: db)
    let authorID = try author.requireID()
    logger.info("created author: id=\(authorID) exists=\(author.$id.exists)")

    let book = Book(
        title: "A Wizard of Earthsea",
        isbn: "978-0-547-77374-6",
        publishedYear: 1968,
        priceCents: 1299,
        authorID: authorID
    )
    logger.info("new book: createdAt=\(String(describing: book.createdAt))")
    try await book.save(on: db)  // no id yet, so save() calls create()
    logger.info("saved book: createdAt=\(String(describing: book.createdAt)) status=\(book.status)")

    book.priceCents = 1399
    logger.info("after edit: hasChanges=\(book.hasChanges)")
    try await book.save(on: db)  // the id exists, so save() calls update()
    logger.info("after update: hasChanges=\(book.hasChanges)")

    let reloaded = try await Book.find(book.id, on: db)
    logger.info("find: price=\(String(describing: reloaded?.priceCents)) author=\(String(describing: reloaded?.$author.id))")
    let missing = try await Book.find(nil, on: db)
    logger.info("find(nil)=\(String(describing: missing))")

    let unsaved = Book(title: "x", isbn: "y", publishedYear: 1, priceCents: 1, authorID: authorID)
    do {
        try await unsaved.delete(on: db)
    } catch {
        logger.info("delete without id: \(error)")
    }

    do {
        let twin = Book(title: "Other", isbn: "978-0-547-77374-6", publishedYear: 1, priceCents: 1, authorID: authorID)
        try await twin.create(on: db)
    } catch {
        logger.info("duplicate isbn: \(error)")
    }

    try await book.delete(on: db)
    logger.info("deleted: exists=\(book.$id.exists)")

    let tags = ["classic", "fantasy", "new"].map { Tag(name: $0) }
    try await tags.create(on: db)  // one INSERT for the whole array
    logger.info("batch created: \(tags.compactMap { $0.id }.count) ids assigned")
    try await tags.delete(on: db)
}

Captured output (the timestamp prefix and the logger label are removed, identifiers shortened):

new author: id=nil exists=false
created author: id=CE4BE93C-... exists=true
new book: createdAt=nil
saved book: createdAt=Optional(2026-10-17 15:30:02 +0000) status=draft
after edit: hasChanges=true
after update: hasChanges=false
find: price=Optional(1399) author=Optional(CE4BE93C-...)
find(nil)=nil
delete without id: ID required
duplicate isbn: constraintUniqueFailed: UNIQUE constraint failed: books.isbn
deleted: exists=false
batch created: 3 ids assigned

What each line teaches:

  • A UUID identifier is assigned by create, not by the initialiser: id is nil before, a value after.

  • status is draft because the initialiser’s default argument supplied it; createdAt is nil until the save.

  • hasChanges is true after an assignment and false after the save. update and save on an unchanged model send no query.

  • $author.id is available on a loaded book without loading the author; that is the parent’s foreign key.

  • A model with no identifier cannot be deleted: FluentError.idRequired.

  • A unique-index violation is an ordinary thrown error from the driver (constraintUniqueFailed on SQLite, a PSQLError with SQLSTATE 23505 on PostgreSQL, an error with code 1062 on MySQL). A foreign-key violation behaves the same way: with the scratch package’s author_id pointing at a missing author the SQLite error is constraintForeignKeyFailed. Map these to 409 Conflict or 422 in an error middleware; the Error Handling page covers the shape.

  • [Model].create(on:) and [Model].delete(on:) exist for any Collection of models and use a single statement, which is the right tool for seeding and for tests.

SQLite enforces foreign keys in Fluent’s driver because the driver enables the pragma; do not assume that for a raw SQLite connection.

Model middleware

Fluent can run code around these operations. A type that conforms to AsyncModelMiddleware is registered with app.databases.middleware.use(MyMiddleware(), on: .sqlite) and receives create, update, delete, softDelete and restore events for one model type, with a next responder. Typical uses are auditing and denormalised counters (see the Transactions, Model Middleware & Soft Delete page). It is not run for the query builder’s bulk update()/delete() or for a raw SQL statement, which is the usual reason it seems to be skipped. The Fluent Queries and Pagination page covers the bulk side.

The lifecycle of one instance

stateDiagram-v2 [*] --> New: Book(...) New --> Persisted: create or save Persisted --> Persisted: update or save Persisted --> SoftDeleted: delete, with a delete timestamp SoftDeleted --> Persisted: restore SoftDeleted --> Gone: delete with force Persisted --> Gone: delete, no delete timestamp Gone --> [*]

New is $id.exists == false. SoftDeleted is a state of the row; the instance you hold still reports exists == true (captured above). Gone is exists == false again, and a model in that state can be `create`d again, which re-inserts it.

Models versus DTOs

A model is a database row with behaviour, not an API contract. There are two ways to send one over HTTP and one of them is usually wrong.

Content conformance on the model

extension Book: Content {} is one line, and it works, because every model is already Codable. What it serialises is the property names (not the database keys), the identifier, the timestamps and loaded relations. Here is what the scratch package produced for a Book encoded with a plain JSONEncoder, and for the same book loaded with .with(\.$author):

{"publishedYear":2000,"updatedAt":813943671.46568,"id":"ADA56026-...","isbn":"9","title":"T","createdAt":813943671.46568,"status":"draft","priceCents":5,"author":{"id":"73046B27-..."}}
{"status":"draft","id":"ADA56026-...","priceCents":5,"updatedAt":813943671.46568,"publishedYear":2000,"isbn":"9","title":"T","createdAt":813943671.46568,"author":{"id":"73046B27-...","name":"A","bio":null}}

(813943671.46568 is Foundation’s default date encoding, seconds since 2001; Vapor’s ContentConfiguration chooses ISO 8601 for the JSON it sends, so the same book through a handler shows "2026-10-17T15:27:51Z".)

The trade-offs of using the model as the response body:

  • The API follows the table. Renaming a property, adding a column or adding a relation changes the JSON that clients see. A User model returned as Content leaks passwordHash, which is the classic mistake. Marking a field out of the JSON is not possible without writing CodingKeys by hand, which is a DTO in disguise.

  • The shape depends on what was loaded. An unloaded @Parent encodes as {"id": ...} and a loaded one as the whole object, so the same endpoint returns different shapes for different code paths. Eager loading has to be a conscious choice.

  • Decoding a request into a model is worse: the client then controls id, createdAt and every other column (mass assignment), and the decoded instance has exists == false, so save inserts.

  • Convenience is real for prototypes, read-only admin tools and the Vapor template’s Todo example, which keeps a toDTO() anyway.

The rule of this section (the API Design, Versioning and DTOs page develops it): models stay inside the persistence layer, handlers decode input DTOs and encode output DTOs, and a toDTO() method (or a mapper) converts. Because DTOs are value types, they are Sendable for free, so they can cross task and actor boundaries that a model must not.

A complete route set with DTOs

The next listing shows the DTOs and the Book to BookDTO conversion, then the route collection. It is the typical CRUD vertical slice: create returns 201, update changes only the supplied fields, delete returns 204, and a missing book is a 404.

import Fluent
import Vapor

struct CreateBook: Content {
    var title: String
    var isbn: String
    var publishedYear: Int
    var priceCents: Int
    var authorID: UUID
}

struct UpdateBook: Content {
    var title: String?
    var priceCents: Int?
    var status: BookStatus?
}

struct BookDTO: Content {
    var id: UUID
    var title: String
    var isbn: String
    var publishedYear: Int
    var priceCents: Int
    var status: BookStatus
    var authorID: UUID
    var createdAt: Date?
    var updatedAt: Date?
}

extension Book {
    func toDTO() throws -> BookDTO {
        BookDTO(
            id: try requireID(),
            title: title,
            isbn: isbn,
            publishedYear: publishedYear,
            priceCents: priceCents,
            status: status,
            authorID: $author.id,
            createdAt: createdAt,
            updatedAt: updatedAt
        )
    }
}

/// A second name for the users table, usable in the same query as the original.
final class Reader: ModelAlias {
    static let name = "readers"
    let model = User()
}
import Fluent
import Vapor

struct BookController: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        let books = routes.grouped("books")
        books.post(use: create)
        books.group(":bookID") { book in
            book.get(use: show)
            book.patch(use: update)
            book.delete(use: delete)
        }
    }

    @Sendable
    func create(req: Request) async throws -> Response {
        let input = try req.content.decode(CreateBook.self)
        guard try await Author.find(input.authorID, on: req.db) != nil else {
            throw Abort(.unprocessableEntity, reason: "Unknown author")
        }
        let book = Book(
            title: input.title,
            isbn: input.isbn,
            publishedYear: input.publishedYear,
            priceCents: input.priceCents,
            authorID: input.authorID
        )
        try await book.create(on: req.db)
        return try await book.toDTO().encodeResponse(status: .created, for: req)
    }

    @Sendable
    func show(req: Request) async throws -> BookDTO {
        try await findBook(req).toDTO()
    }

    @Sendable
    func update(req: Request) async throws -> BookDTO {
        let book = try await findBook(req)
        let patch = try req.content.decode(UpdateBook.self)
        if let title = patch.title { book.title = title }
        if let price = patch.priceCents { book.priceCents = price }
        if let status = patch.status { book.status = status }
        try await book.update(on: req.db)
        return try book.toDTO()
    }

    @Sendable
    func delete(req: Request) async throws -> HTTPStatus {
        try await findBook(req).delete(on: req.db)
        return .noContent
    }

    private func findBook(_ req: Request) async throws -> Book {
        guard let book = try await Book.find(req.parameters.get("bookID"), on: req.db) else {
            throw Abort(.notFound)
        }
        return book
    }
}

Run through app.testing() against the in-memory database (identifiers shortened, one object per line):

POST 201 Created {"authorID":"73046B27-...","status":"draft","id":"EA440E49-...","priceCents":999,"updatedAt":"2026-10-17T15:27:51Z","title":"Dune","isbn":"978-0-441-17271-9","publishedYear":1965,"createdAt":"2026-10-17T15:27:51Z"}
PATCH 200 OK {... "status":"published","priceCents":1099 ...}
GET 200 OK
DELETE 204 No Content
GET after delete 404 Not Found

Notes on the code:

  • UpdateBook has only optional fields, and update assigns only the ones present. The assigned fields are exactly the ones hasChanges sees, so a PATCH with {} sends no SQL.

  • find(req.parameters.get("bookID"), on:) uses the optional-identifier overload; a malformed UUID in the path is a 404 in this handler rather than a 400. Use req.parameters.require("bookID", as: UUID.self) when you want the 400.

  • The handler functions are marked @Sendable because they are passed to the router as @Sendable closures; the Routing page covers why.

  • create uses Author.find first to give a clear 422; without it the database’s foreign-key error would surface as a 500 unless an error middleware maps it.

  • DTO conversion is where the identifier is unwrapped with requireID(): once a row exists, a missing identifier is a bug, and the thrown error is a 500 with a clear cause.

ModelAlias

A query can refer to the same table once only, under the table name. When it must refer to it twice (a join to users for the reviewer and again for the loan holder), the second reference needs a different name. ModelAlias provides it: it is a type that is the same model under another table alias, usable wherever the original type is.

final class Reader: ModelAlias {
    static let name = "readers"
    let model = User()
}

let reviews = try await Review.query(on: db)
    .join(Reader.self, on: \Review.$user.$id == \Reader.$id)
    .all()
for review in reviews {
    print(try review.joined(Reader.self).email)    // r@example.test
}

The declaration is part of the listing of Dtos.swift above, and the loop was run: it printed the reviewer’s address. Aliases are about joins and the Fluent Queries and Pagination page covers them with the other query features; they appear here because they are a model-level declaration with no database counterpart.

Why models are final classes with @unchecked Sendable

Model inherits Sendable through Fields, so the compiler asks every model class to prove it is safe to share between tasks. A class can only do that with immutable stored properties, and a model is made of mutable property-wrapper instances by design, so the proof is impossible. The diagnostic is:

error: stored property '_id' of 'Sendable'-conforming class 'NotSendable' is mutable

The model template that Vapor ships answers with final class Todo: Model, @unchecked Sendable, which tells the compiler to stop checking and trusts the author. The Swift Concurrency in Vapor page gives the whole reasoning with the compiler messages; the rules it derives are the ones that matter here:

  • one task loads, changes and saves a model; do not give one instance to two concurrent child tasks;

  • send DTOs, not models, across actors and tasks;

  • never store a model in a global, an actor or app.storage and reuse it across requests: it carries exists and the original values of one save cycle, and update on a stale instance overwrites newer data.

A model is not thread-safe, @unchecked Sendable is a promise, and nothing but those rules keeps it. The same applies to a Fields type used in a @Group, as the Location class above shows.

Common errors and pitfalls

Symptom Cause and fix

type 'X' does not conform to protocol 'Fields', "protocol requires initializer 'init()'"

A custom initialiser removed the synthesised init(). Add init() {}.

stored property '_id' of 'Sendable'-conforming class ... is mutable

Add @unchecked Sendable, as above.

generic type alias 'Field' requires that 'T' conform to 'Decodable'

The field’s type is not Codable. Make it Codable or store a raw value.

Fatal error: Cannot access field before it is initialized or fetched: title

A non-optional field was read before it was set or loaded (Book(), or a query that selected only some fields).

Precondition failed at Model+Concurrency.swift

create on a model that exists, or update on one that does not. Use save.

ID required (FluentError.idRequired)

requireID(), delete or update on a model without an identifier.

constraintUniqueFailed / SQLSTATE 23505 / MySQL 1062

Duplicate value in a unique column (isbn). Check first or map the error to 409.

constraintForeignKeyFailed / SQLSTATE 23503

A parent that does not exist (author_id). Validate the parent first.

"no such column" / "column …​ does not exist" at the first query

The key in the model and the column in the migration differ (published_year against publishedyear). Compare both.

Update seems to do nothing

The model had no changes (hasChanges == false), or you changed a different instance from the one you saved.

A decoded model always inserts

exists is false for any instance not loaded from the database. Load, assign, save.

@Timestamp stays nil

It is set by the trigger, so nil before the save is normal; a bulk query update() does not touch it.

Soft-deleted row blocks a new one

The unique constraint still sees it. Hard-delete (force: true) or make the constraint partial in SQL.

On SQLite specifically, a model with a user-generated identifier that was saved with the identifier left nil was inserted without an error in the scratch package, because SQLite permits a NULL in a text primary key; PostgreSQL and MySQL reject it. Treat the missing identifier as a bug that SQLite hides.

Version notes

  • Fluent 4 (with Vapor 4) is the line documented here. Models in Vapor 3 conformed to a Model protocol with a Content and a Migration conformance and took an Int or UUID id through PostgreSQLModel and friends; Fluent 4 replaced those with the property wrappers on this page and a separate Migration type, so Vapor 3 model code does not port by renaming.

  • AsyncMigration and the async CRUD methods arrived with Swift concurrency in Vapor 4.5x-era releases; in FluentKit 1.57 the EventLoopFuture methods are thin wrappers over the async ones.

  • @ID(key: .id) was replaced by @ID in current templates; the old spelling still compiles.

  • @Boolean and @OptionalBoolean (FluentKit 1.17) exist for legacy column formats.

  • Swift 6 language mode requires Sendable models: in Vapor 4.122 and Fluent 4.13 that is the @unchecked Sendable above.

  • Vapor 5 (5.0.0-beta.4) changes the Vapor side (service container, Request and Application APIs); at the time of writing the Fluent packages were still released against Vapor 4 and the model definitions above are the ones to expect, but this could not be verified for a Vapor 5 Fluent release because none was published.

Where the books are outdated

  • Condon et al. (Server-Side Swift with Vapor, Kodeco, chapters 5 to 7) builds Acronym and User models with @ID(key: .id), @Field(key:), @Parent and @Siblings, migrations and CRUD over a REST API. The property wrappers and the table-versus-model split are unchanged and still correct. What changed: the models in the book are final class X: Model, Content, and the book does not mark them Sendable, so the code does not compile in the Swift 6 language mode without the @unchecked Sendable and the Content conformance should be moved to DTOs as recommended above; handlers that call .save(on:) with flatMap become try await ... .save(on:).

  • Yeung (Packt) and Patel (Packt) are written for Vapor 3 or older. Their models conform to PostgreSQLModel, SQLiteModel or MySQLModel, use var id: Int? with static let entity, and Migration conformances on the model type; none of that exists in Fluent 4. The data modelling ideas carry over; the code does not.

  • Both avoid the DTO question by returning models directly from handlers. Treat that as a prototype shortcut, not a pattern.