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 (thekeyof every property wrapper); -
it holds the values of one row while your code works with it, including the state Fluent needs to decide between
INSERTandUPDATE(theexistsflag 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).
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
}
}
Source: Vapor docs: Fluent model
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, andfinalbecause the wrappers are initialised in the class body. The@unchecked Sendableis explained in the last section of the page. -
static let schema- the table name. It is the only static requirement ofModel. A second, optionalstatic let space: String?names the database schema (PostgreSQL) or database (MySQL) that contains the table; leave itnilfor the default. -
@ID- the primary key, aUUID?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, whoseauthor_idcolumn is a real field. The Fluent Relations page covers them. -
init()- required, see below. -
a second
initwith 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(). Writinginit() {}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 |
|---|---|---|
|
Fluent calls |
|
|
Fluent sends |
|
|
Your code sets the value before saving; Fluent does not generate one. |
every other type, such as |
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):
-
Publisherhas anIntkey that the database generates, soidisniluntil the insert has run andPublisher.find(1, on: db)works with an integer. -
Editionhas aStringkey, an ISBN, that the application supplies.generatedBy: .useris the default forString; 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
Source: Vapor docs: Fluent model, Exists
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
Source: Vapor docs: Fluent model, Field
What the property wrappers do is small and worth knowing:
-
The value type must be
Codable(andSendablein Fluent 4.13). Passing a type that is notCodableproduces "generic type alias 'Field' requires that 'Opaque' conform to 'Decodable'" (and one more forEncodable). -
Simple types (
String,Int,Bool,Double,Date,UUID,Data) map to native column types. Anything else ([String], a struct, a dictionary) is stored through itsCodablerepresentation: 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
@Fieldof anOptionaltype compiles but is not the same as@OptionalField. Use@OptionalFieldfor a nullable column, as the official page does. -
Do not add
willSetordidSetobservers 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 |
|---|---|
|
when the row is inserted |
|
when the row is inserted and every time |
|
when |
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 |
|---|---|---|
|
the driver’s native date-time type |
|
|
an ISO 8601 string |
|
|
seconds since 1970 including a fraction, a |
|
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()
}
}
Source: Vapor docs: Fluent model and Vapor docs: Fluent schema
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 |
|---|---|
|
|
|
|
|
|
|
|
|
undoes a soft delete. |
|
the row with that identifier, or |
|
the identifier or |
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)
}
Source: Vapor docs: Fluent model and Vapor API documentation
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
UUIDidentifier is assigned bycreate, not by the initialiser:idisnilbefore, a value after. -
statusisdraftbecause the initialiser’s default argument supplied it;createdAtisniluntil the save. -
hasChangesis true after an assignment and false after the save.updateandsaveon an unchanged model send no query. -
$author.idis 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 (
constraintUniqueFailedon SQLite, aPSQLErrorwith SQLSTATE23505on PostgreSQL, an error with code 1062 on MySQL). A foreign-key violation behaves the same way: with the scratch package’sauthor_idpointing at a missing author the SQLite error isconstraintForeignKeyFailed. Map these to409 Conflictor422in an error middleware; the Error Handling page covers the shape. -
[Model].create(on:)and[Model].delete(on:)exist for anyCollectionof 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
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
Usermodel returned asContentleakspasswordHash, which is the classic mistake. Marking a field out of the JSON is not possible without writingCodingKeysby hand, which is a DTO in disguise. -
The shape depends on what was loaded. An unloaded
@Parentencodes 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,createdAtand every other column (mass assignment), and the decoded instance hasexists == false, sosaveinserts. -
Convenience is real for prototypes, read-only admin tools and the Vapor template’s
Todoexample, which keeps atoDTO()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()
}
Source: Vapor docs: Fluent model and Vapor docs: Content
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
}
}
Source: Vapor docs: Routing and Vapor docs: Fluent model
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:
-
UpdateBookhas only optional fields, andupdateassigns only the ones present. The assigned fields are exactly the oneshasChangessees, so aPATCHwith{}sends no SQL. -
find(req.parameters.get("bookID"), on:)uses the optional-identifier overload; a malformed UUID in the path is a404in this handler rather than a400. Usereq.parameters.require("bookID", as: UUID.self)when you want the400. -
The handler functions are marked
@Sendablebecause they are passed to the router as@Sendableclosures; the Routing page covers why. -
createusesAuthor.findfirst to give a clear422; without it the database’s foreign-key error would surface as a500unless an error middleware maps it. -
DTOconversion is where the identifier is unwrapped withrequireID(): once a row exists, a missing identifier is a bug, and the thrown error is a500with 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
}
Source: Vapor docs: Fluent query and Vapor API documentation
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.storageand reuse it across requests: it carriesexistsand the original values of one save cycle, andupdateon 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 |
|---|---|
|
A custom initialiser removed the synthesised |
|
Add |
|
The field’s type is not |
|
A non-optional field was read before it was set or loaded ( |
|
|
|
|
|
Duplicate value in a unique column ( |
|
A parent that does not exist ( |
"no such column" / "column … does not exist" at the first query |
The key in the model and the column in the migration differ ( |
Update seems to do nothing |
The model had no changes ( |
A decoded model always inserts |
|
|
It is set by the trigger, so |
Soft-deleted row blocks a new one |
The unique constraint still sees it. Hard-delete ( |
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
Modelprotocol with aContentand aMigrationconformance and took anIntorUUIDidthroughPostgreSQLModeland friends; Fluent 4 replaced those with the property wrappers on this page and a separateMigrationtype, so Vapor 3 model code does not port by renaming. -
AsyncMigrationand theasyncCRUD methods arrived with Swift concurrency in Vapor 4.5x-era releases; in FluentKit 1.57 theEventLoopFuturemethods are thin wrappers over the async ones. -
@ID(key: .id)was replaced by@IDin current templates; the old spelling still compiles. -
@Booleanand@OptionalBoolean(FluentKit 1.17) exist for legacy column formats. -
Swift 6 language mode requires
Sendablemodels: in Vapor 4.122 and Fluent 4.13 that is the@unchecked Sendableabove. -
Vapor 5 (5.0.0-beta.4) changes the Vapor side (service container,
RequestandApplicationAPIs); 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
AcronymandUsermodels with@ID(key: .id),@Field(key:),@Parentand@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 arefinal class X: Model, Content, and the book does not mark themSendable, so the code does not compile in the Swift 6 language mode without the@unchecked Sendableand theContentconformance should be moved to DTOs as recommended above; handlers that call.save(on:)withflatMapbecometry await ... .save(on:). -
Yeung (Packt) and Patel (Packt) are written for Vapor 3 or older. Their models conform to
PostgreSQLModel,SQLiteModelorMySQLModel, usevar id: Int?withstatic let entity, andMigrationconformances 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.